Compare commits

..

1 commit

Author SHA1 Message Date
Patrick Britton d990aec83e chore: refresh repo hooks 2026-02-17 03:20:53 -06:00
209 changed files with 2767 additions and 43523 deletions

View file

@ -46,8 +46,7 @@
"Bash(flutter build:*)",
"Bash(find:*)",
"Bash(flutter upgrade:*)",
"Bash(xargs:*)",
"Bash(go vet:*)"
"Bash(xargs:*)"
]
}
}

View file

@ -3,16 +3,13 @@
import AdminShell from '@/components/AdminShell';
import { api } from '@/lib/api';
import { useEffect, useState } from 'react';
import { Sliders, Save, RefreshCw, BarChart2 } from 'lucide-react';
import { Sliders, Save, RefreshCw } from 'lucide-react';
export default function AlgorithmPage() {
const [configs, setConfigs] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [editValues, setEditValues] = useState<Record<string, string>>({});
const [saving, setSaving] = useState<string | null>(null);
const [scores, setScores] = useState<any[]>([]);
const [scoresLoading, setScoresLoading] = useState(false);
const [showScores, setShowScores] = useState(false);
const fetchConfig = () => {
setLoading(true);
@ -38,15 +35,6 @@ export default function AlgorithmPage() {
setSaving(null);
};
const loadScores = () => {
setScoresLoading(true);
setShowScores(true);
api.getFeedScores()
.then((data) => setScores(data.scores ?? []))
.catch(() => {})
.finally(() => setScoresLoading(false));
};
const groupedConfigs = {
feed: configs.filter((c) => c.key.startsWith('feed_')),
moderation: configs.filter((c) => c.key.startsWith('moderation_')),
@ -180,68 +168,6 @@ export default function AlgorithmPage() {
)}
</div>
)}
{/* Feed Scores Viewer */}
<div className="mt-8">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<BarChart2 className="w-5 h-5 text-gray-600" />
<h2 className="text-lg font-semibold text-gray-800">Live Feed Scores</h2>
</div>
<button
onClick={loadScores}
className="flex items-center gap-1.5 px-3 py-1.5 border rounded-lg text-sm hover:bg-gray-50"
>
<RefreshCw className={`w-4 h-4 ${scoresLoading ? 'animate-spin' : ''}`} />
{showScores ? 'Refresh' : 'Load Scores'}
</button>
</div>
{showScores && (
<div className="bg-white rounded-xl border overflow-hidden">
{scoresLoading ? (
<div className="p-6 text-center text-gray-400">Loading scores</div>
) : scores.length === 0 ? (
<div className="p-6 text-center text-gray-400">No scored posts yet</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-gray-50 border-b">
<tr>
<th className="px-4 py-3 text-left font-medium text-gray-600">Post</th>
<th className="px-4 py-3 text-right font-medium text-gray-600">Total</th>
<th className="px-4 py-3 text-right font-medium text-gray-600">Engage</th>
<th className="px-4 py-3 text-right font-medium text-gray-600">Quality</th>
<th className="px-4 py-3 text-right font-medium text-gray-600">Recency</th>
<th className="px-4 py-3 text-right font-medium text-gray-600">Network</th>
<th className="px-4 py-3 text-right font-medium text-gray-600">Personal</th>
<th className="px-4 py-3 text-left font-medium text-gray-600">Updated</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{scores.map((s) => (
<tr key={s.post_id} className="hover:bg-gray-50">
<td className="px-4 py-2.5 max-w-xs">
<p className="truncate text-gray-800" title={s.excerpt}>{s.excerpt || '—'}</p>
<p className="text-xs text-gray-400 font-mono">{s.post_id.slice(0, 8)}</p>
</td>
<td className="px-4 py-2.5 text-right font-semibold text-blue-700">
{Number(s.total_score).toFixed(2)}
</td>
<td className="px-4 py-2.5 text-right text-gray-600">{Number(s.engagement_score).toFixed(2)}</td>
<td className="px-4 py-2.5 text-right text-gray-600">{Number(s.quality_score).toFixed(2)}</td>
<td className="px-4 py-2.5 text-right text-gray-600">{Number(s.recency_score).toFixed(2)}</td>
<td className="px-4 py-2.5 text-right text-gray-600">{Number(s.network_score).toFixed(2)}</td>
<td className="px-4 py-2.5 text-right text-gray-600">{Number(s.personalization).toFixed(2)}</td>
<td className="px-4 py-2.5 text-gray-400 text-xs">{new Date(s.updated_at).toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
</div>
</AdminShell>
);
}

View file

@ -1,136 +0,0 @@
'use client';
import AdminShell from '@/components/AdminShell';
import { api } from '@/lib/api';
import { formatDateTime } from '@/lib/utils';
import { useEffect, useState } from 'react';
import { ScrollText, RefreshCw, ChevronLeft, ChevronRight } from 'lucide-react';
const ACTION_COLORS: Record<string, string> = {
ban: 'bg-red-100 text-red-700',
suspend: 'bg-orange-100 text-orange-700',
activate: 'bg-green-100 text-green-700',
delete: 'bg-red-100 text-red-700',
admin_create_user: 'bg-blue-100 text-blue-700',
admin_import_content: 'bg-blue-100 text-blue-700',
waitlist_update: 'bg-purple-100 text-purple-700',
reset_feed_impressions: 'bg-yellow-100 text-yellow-700',
};
function actionColor(action: string) {
return ACTION_COLORS[action] || 'bg-gray-100 text-gray-600';
}
export default function AuditLogPage() {
const [entries, setEntries] = useState<any[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(0);
const limit = 50;
const fetchLog = (p = page) => {
setLoading(true);
api.getAuditLog({ limit, offset: p * limit })
.then((data) => {
setEntries(data.entries || []);
setTotal(data.total || 0);
})
.catch(() => {})
.finally(() => setLoading(false));
};
useEffect(() => { fetchLog(page); }, [page]);
const totalPages = Math.max(1, Math.ceil(total / limit));
return (
<AdminShell>
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
<ScrollText className="w-6 h-6 text-brand-500" /> Admin Audit Log
</h1>
<p className="text-sm text-gray-500 mt-1">Every admin action is recorded here</p>
</div>
<button onClick={() => fetchLog(page)} className="btn-secondary text-sm flex items-center gap-1">
<RefreshCw className="w-4 h-4" /> Refresh
</button>
</div>
<div className="card overflow-hidden">
{loading ? (
<div className="p-8 animate-pulse space-y-3">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="h-10 bg-warm-300 rounded" />
))}
</div>
) : entries.length === 0 ? (
<div className="p-8 text-center text-gray-400">No audit log entries found.</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-warm-100 border-b border-warm-300">
<tr>
<th className="px-4 py-3 text-left font-medium text-gray-600">When</th>
<th className="px-4 py-3 text-left font-medium text-gray-600">Admin</th>
<th className="px-4 py-3 text-left font-medium text-gray-600">Action</th>
<th className="px-4 py-3 text-left font-medium text-gray-600">Target</th>
<th className="px-4 py-3 text-left font-medium text-gray-600">Details</th>
</tr>
</thead>
<tbody className="divide-y divide-warm-100">
{entries.map((e) => (
<tr key={e.id} className="hover:bg-warm-50">
<td className="px-4 py-2.5 text-gray-500 text-xs whitespace-nowrap">
{e.created_at ? formatDateTime(e.created_at) : '—'}
</td>
<td className="px-4 py-2.5 text-gray-700 font-medium">
{e.actor_handle ? `@${e.actor_handle}` : e.actor_id ? e.actor_id.slice(0, 8) + '…' : '—'}
</td>
<td className="px-4 py-2.5">
<span className={`inline-flex px-2 py-0.5 rounded-full text-xs font-medium ${actionColor(e.action)}`}>
{e.action?.replace(/_/g, ' ')}
</span>
</td>
<td className="px-4 py-2.5 text-gray-500 text-xs">
{e.target_type && <span className="font-medium text-gray-700">{e.target_type}</span>}
{e.target_id && <span className="ml-1 font-mono">{String(e.target_id).slice(0, 8)}</span>}
</td>
<td className="px-4 py-2.5 text-gray-500 text-xs max-w-xs truncate" title={e.details}>
{e.details || '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between px-4 py-3 border-t border-warm-200">
<p className="text-xs text-gray-500">
Page {page + 1} of {totalPages} ({total} entries)
</p>
<div className="flex items-center gap-2">
<button
onClick={() => setPage((p) => Math.max(0, p - 1))}
disabled={page === 0}
className="p-1.5 rounded border border-warm-300 disabled:opacity-40 hover:bg-warm-100"
>
<ChevronLeft className="w-4 h-4" />
</button>
<button
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
disabled={page >= totalPages - 1}
className="p-1.5 rounded border border-warm-300 disabled:opacity-40 hover:bg-warm-100"
>
<ChevronRight className="w-4 h-4" />
</button>
</div>
</div>
)}
</div>
</AdminShell>
);
}

View file

@ -1,161 +0,0 @@
'use client';
import AdminShell from '@/components/AdminShell';
import { api } from '@/lib/api';
import { statusColor, formatDateTime } from '@/lib/utils';
import { useEffect, useState } from 'react';
import { ShieldAlert, CheckCircle, XCircle, Lock } from 'lucide-react';
export default function CapsuleReportsPage() {
const [reports, setReports] = useState<any[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [statusFilter, setStatusFilter] = useState('pending');
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const fetchReports = () => {
setLoading(true);
api.listCapsuleReports({ limit: 50, status: statusFilter })
.then((data) => { setReports(data.reports); setTotal(data.total); })
.catch(() => {})
.finally(() => setLoading(false));
};
useEffect(() => { fetchReports(); }, [statusFilter]);
const handleUpdate = async (id: string, status: string) => {
try {
await api.updateCapsuleReportStatus(id, status);
fetchReports();
} catch {}
};
const toggleExpand = (id: string) => {
setExpanded((prev) => {
const s = new Set(prev);
s.has(id) ? s.delete(id) : s.add(id);
return s;
});
};
return (
<AdminShell>
<div className="mb-6 flex items-center justify-between">
<div>
<div className="flex items-center gap-2">
<Lock className="w-5 h-5 text-gray-600" />
<h1 className="text-2xl font-bold text-gray-900">Capsule Reports</h1>
</div>
<p className="text-sm text-gray-500 mt-1">
{total} {statusFilter} reports from encrypted private groups.
Members voluntarily submitted decrypted evidence.
</p>
</div>
<select className="input w-auto" value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}>
<option value="pending">Pending</option>
<option value="reviewed">Reviewed</option>
<option value="actioned">Actioned</option>
<option value="dismissed">Dismissed</option>
</select>
</div>
{loading ? (
<div className="space-y-4">
{[...Array(3)].map((_, i) => (
<div key={i} className="card p-6 animate-pulse">
<div className="h-16 bg-warm-300 rounded" />
</div>
))}
</div>
) : reports.length === 0 ? (
<div className="card p-12 text-center">
<ShieldAlert className="w-12 h-12 text-green-400 mx-auto mb-3" />
<p className="text-gray-500 font-medium">No {statusFilter} capsule reports</p>
</div>
) : (
<div className="card overflow-hidden">
<table className="w-full">
<thead className="bg-warm-200">
<tr>
<th className="table-header">Reporter</th>
<th className="table-header">Capsule Group</th>
<th className="table-header">Reason</th>
<th className="table-header">Evidence (decrypted by reporter)</th>
<th className="table-header">Status</th>
<th className="table-header">Date</th>
<th className="table-header">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-warm-300">
{reports.map((report) => {
const isExpanded = expanded.has(report.id);
const sample = report.decrypted_sample as string | null;
return (
<tr key={report.id} className="hover:bg-warm-50 transition-colors align-top">
<td className="table-cell text-sm text-brand-600">
@{report.reporter_handle || '—'}
</td>
<td className="table-cell">
<span className="inline-flex items-center gap-1 text-sm">
<Lock className="w-3 h-3 text-gray-400 shrink-0" />
{report.capsule_name || report.capsule_id}
</span>
</td>
<td className="table-cell">
<span className="badge bg-orange-50 text-orange-700">{report.reason}</span>
</td>
<td className="table-cell max-w-xs">
{sample ? (
<div>
<p className={`text-sm text-gray-700 ${isExpanded ? '' : 'line-clamp-2'}`}>
{sample}
</p>
{sample.length > 120 && (
<button
className="text-xs text-brand-500 hover:underline mt-1"
onClick={() => toggleExpand(report.id)}
>
{isExpanded ? 'Show less' : 'Show more'}
</button>
)}
</div>
) : (
<span className="text-xs text-gray-400 italic">No evidence provided</span>
)}
</td>
<td className="table-cell">
<span className={`badge ${statusColor(report.status)}`}>{report.status}</span>
</td>
<td className="table-cell text-xs text-gray-500">
{formatDateTime(report.created_at)}
</td>
<td className="table-cell">
{report.status === 'pending' && (
<div className="flex gap-1">
<button
onClick={() => handleUpdate(report.id, 'actioned')}
className="p-1.5 bg-green-50 text-green-700 rounded hover:bg-green-100"
title="Action taken"
>
<CheckCircle className="w-4 h-4" />
</button>
<button
onClick={() => handleUpdate(report.id, 'dismissed')}
className="p-1.5 bg-gray-50 text-gray-600 rounded hover:bg-gray-100"
title="Dismiss"
>
<XCircle className="w-4 h-4" />
</button>
</div>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</AdminShell>
);
}

View file

@ -1,201 +0,0 @@
'use client';
import AdminShell from '@/components/AdminShell';
import { api } from '@/lib/api';
import { formatDate } from '@/lib/utils';
import { useEffect, useState } from 'react';
import { Search, Trash2, Users, RotateCcw } from 'lucide-react';
export default function GroupsPage() {
const [groups, setGroups] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
const [offset, setOffset] = useState(0);
const [selectedGroup, setSelectedGroup] = useState<any | null>(null);
const [members, setMembers] = useState<any[]>([]);
const [membersLoading, setMembersLoading] = useState(false);
const limit = 50;
const fetchGroups = () => {
setLoading(true);
api.listGroups({ search: search || undefined, limit, offset })
.then((data) => setGroups(data.groups ?? []))
.catch(() => {})
.finally(() => setLoading(false));
};
useEffect(() => { fetchGroups(); }, [offset]);
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
setOffset(0);
fetchGroups();
};
const openGroup = async (group: any) => {
setSelectedGroup(group);
setMembersLoading(true);
try {
const data = await api.listGroupMembers(group.id);
setMembers(data.members ?? []);
} catch {
setMembers([]);
} finally {
setMembersLoading(false);
}
};
const deactivateGroup = async (id: string) => {
if (!confirm('Deactivate this group?')) return;
try {
await api.deleteGroup(id);
setGroups((prev) => prev.filter((g) => g.id !== id));
if (selectedGroup?.id === id) setSelectedGroup(null);
} catch (e: any) {
alert(e.message);
}
};
const removeMember = async (groupId: string, userId: string) => {
if (!confirm('Remove this member? Key rotation will be triggered.')) return;
try {
await api.removeGroupMember(groupId, userId);
setMembers((prev) => prev.filter((m) => m.user_id !== userId));
} catch (e: any) {
alert(e.message);
}
};
return (
<AdminShell>
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Groups & Capsules</h1>
<p className="text-sm text-gray-500 mt-1">Manage community groups and E2EE capsules</p>
</div>
</div>
<form onSubmit={handleSearch} className="mb-4 flex gap-2">
<div className="relative flex-1 max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 w-4 h-4" />
<input
className="pl-9 pr-4 py-2 border rounded-lg w-full text-sm"
placeholder="Search groups..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<button type="submit" className="px-4 py-2 bg-navy-600 text-white rounded-lg text-sm font-medium bg-blue-700 hover:bg-blue-800">
Search
</button>
</form>
<div className="flex gap-6">
{/* Groups list */}
<div className="flex-1 bg-white rounded-xl border overflow-hidden">
{loading ? (
<div className="p-8 text-center text-gray-400">Loading</div>
) : groups.length === 0 ? (
<div className="p-8 text-center text-gray-400">No groups found</div>
) : (
<table className="w-full text-sm">
<thead className="bg-gray-50 border-b">
<tr>
<th className="px-4 py-3 text-left font-medium text-gray-600">Name</th>
<th className="px-4 py-3 text-left font-medium text-gray-600">Type</th>
<th className="px-4 py-3 text-center font-medium text-gray-600">Members</th>
<th className="px-4 py-3 text-center font-medium text-gray-600">Key v</th>
<th className="px-4 py-3 text-center font-medium text-gray-600">Rotation</th>
<th className="px-4 py-3 text-left font-medium text-gray-600">Created</th>
<th className="px-4 py-3" />
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{groups.map((g) => (
<tr
key={g.id}
className={`hover:bg-gray-50 cursor-pointer ${selectedGroup?.id === g.id ? 'bg-blue-50' : ''}`}
onClick={() => openGroup(g)}
>
<td className="px-4 py-3 font-medium text-gray-900">{g.name}</td>
<td className="px-4 py-3">
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${g.is_private ? 'bg-purple-100 text-purple-700' : 'bg-green-100 text-green-700'}`}>
{g.is_private ? 'Capsule' : 'Public'}
</span>
</td>
<td className="px-4 py-3 text-center">{g.member_count}</td>
<td className="px-4 py-3 text-center text-gray-500">v{g.key_version}</td>
<td className="px-4 py-3 text-center">
{g.key_rotation_needed && (
<span className="px-2 py-0.5 rounded-full text-xs bg-amber-100 text-amber-700">Pending</span>
)}
</td>
<td className="px-4 py-3 text-gray-500">{formatDate(g.created_at)}</td>
<td className="px-4 py-3 text-right">
<button
onClick={(e) => { e.stopPropagation(); deactivateGroup(g.id); }}
className="text-red-500 hover:text-red-700 p-1"
title="Deactivate group"
>
<Trash2 className="w-4 h-4" />
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
<div className="px-4 py-3 border-t flex items-center gap-3">
<button disabled={offset === 0} onClick={() => setOffset(Math.max(0, offset - limit))}
className="text-sm px-3 py-1.5 rounded border disabled:opacity-40">Prev</button>
<button disabled={groups.length < limit} onClick={() => setOffset(offset + limit)}
className="text-sm px-3 py-1.5 rounded border disabled:opacity-40">Next</button>
</div>
</div>
{/* Member panel */}
{selectedGroup && (
<div className="w-72 bg-white rounded-xl border overflow-hidden self-start">
<div className="px-4 py-3 border-b bg-gray-50 flex items-center gap-2">
<Users className="w-4 h-4 text-gray-500" />
<span className="font-semibold text-sm text-gray-800">{selectedGroup.name}</span>
</div>
{membersLoading ? (
<div className="p-6 text-center text-gray-400 text-sm">Loading members</div>
) : members.length === 0 ? (
<div className="p-6 text-center text-gray-400 text-sm">No members</div>
) : (
<ul className="divide-y divide-gray-100 max-h-96 overflow-y-auto">
{members.map((m) => (
<li key={m.user_id} className="px-4 py-2.5 flex items-center justify-between text-sm">
<div>
<p className="font-medium text-gray-800">{m.username || m.display_name}</p>
<p className="text-xs text-gray-400">{m.role}</p>
</div>
{m.role !== 'owner' && (
<button
onClick={() => removeMember(selectedGroup.id, m.user_id)}
className="text-red-400 hover:text-red-600 p-1"
title="Remove member"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</li>
))}
</ul>
)}
{selectedGroup.key_rotation_needed && (
<div className="px-4 py-3 border-t bg-amber-50">
<div className="flex items-center gap-2 text-amber-700 text-xs">
<RotateCcw className="w-3.5 h-3.5" />
Key rotation pending will auto-complete next time an admin opens this capsule.
</div>
</div>
)}
</div>
)}
</div>
</AdminShell>
);
}

View file

@ -1,127 +0,0 @@
'use client';
import AdminShell from '@/components/AdminShell';
import { api } from '@/lib/api';
import { formatDate } from '@/lib/utils';
import { useEffect, useState } from 'react';
import { RefreshCw, Wrench, Play, CheckCircle } from 'lucide-react';
export default function QuipsPage() {
const [quips, setQuips] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [repairing, setRepairing] = useState<Set<string>>(new Set());
const [repaired, setRepaired] = useState<Set<string>>(new Set());
const fetchQuips = () => {
setLoading(true);
api.getBrokenQuips()
.then((data) => setQuips(data.quips ?? []))
.catch(() => {})
.finally(() => setLoading(false));
};
useEffect(() => { fetchQuips(); }, []);
const repairQuip = async (quip: any) => {
setRepairing((prev) => new Set(prev).add(quip.id));
try {
await api.repairQuip(quip.id);
setRepaired((prev) => new Set(prev).add(quip.id));
setQuips((prev) => prev.filter((q) => q.id !== quip.id));
} catch (e: any) {
alert(`Repair failed: ${e.message}`);
} finally {
setRepairing((prev) => { const s = new Set(prev); s.delete(quip.id); return s; });
}
};
const repairAll = async () => {
const list = [...quips];
for (const q of list) {
await repairQuip(q);
}
};
return (
<AdminShell>
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Quip Repair</h1>
<p className="text-sm text-gray-500 mt-1">
Videos missing thumbnails server extracts frames via FFmpeg and uploads to R2.
</p>
</div>
<div className="flex gap-2">
<button
onClick={fetchQuips}
className="flex items-center gap-1.5 px-3 py-2 border rounded-lg text-sm hover:bg-gray-50"
>
<RefreshCw className="w-4 h-4" /> Reload
</button>
{quips.length > 0 && (
<button
onClick={repairAll}
className="flex items-center gap-1.5 px-4 py-2 bg-blue-700 text-white rounded-lg text-sm font-medium hover:bg-blue-800"
>
<Wrench className="w-4 h-4" /> Repair All ({quips.length})
</button>
)}
</div>
</div>
{repaired.size > 0 && (
<div className="mb-4 px-4 py-2.5 bg-green-50 border border-green-200 rounded-lg text-sm text-green-700 flex items-center gap-2">
<CheckCircle className="w-4 h-4" /> {repaired.size} quip{repaired.size !== 1 ? 's' : ''} repaired this session.
</div>
)}
<div className="bg-white rounded-xl border overflow-hidden">
{loading ? (
<div className="p-8 text-center text-gray-400">Loading</div>
) : quips.length === 0 ? (
<div className="p-8 text-center text-gray-400">
{repaired.size > 0 ? '✓ All quips repaired!' : 'No broken quips found.'}
</div>
) : (
<table className="w-full text-sm">
<thead className="bg-gray-50 border-b">
<tr>
<th className="px-4 py-3 text-left font-medium text-gray-600">Post ID</th>
<th className="px-4 py-3 text-left font-medium text-gray-600">Video URL</th>
<th className="px-4 py-3 text-left font-medium text-gray-600">Created</th>
<th className="px-4 py-3 text-right font-medium text-gray-600">Action</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{quips.map((q) => (
<tr key={q.id} className="hover:bg-gray-50">
<td className="px-4 py-3 font-mono text-xs text-gray-500">{q.id.slice(0, 8)}</td>
<td className="px-4 py-3 max-w-xs">
<span className="truncate block text-xs text-gray-600" title={q.video_url}>
{q.video_url}
</span>
</td>
<td className="px-4 py-3 text-gray-500">{formatDate(q.created_at)}</td>
<td className="px-4 py-3 text-right">
<button
onClick={() => repairQuip(q)}
disabled={repairing.has(q.id)}
className="flex items-center gap-1.5 ml-auto px-3 py-1.5 bg-amber-500 text-white rounded-lg text-xs font-medium hover:bg-amber-600 disabled:opacity-50"
>
{repairing.has(q.id) ? (
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
) : (
<Play className="w-3.5 h-3.5" />
)}
{repairing.has(q.id) ? 'Repairing…' : 'Repair'}
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</AdminShell>
);
}

View file

@ -5,7 +5,7 @@ import { api } from '@/lib/api';
import { statusColor, formatDateTime } from '@/lib/utils';
import { useEffect, useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { ArrowLeft, Shield, Ban, CheckCircle, XCircle, Star, RotateCcw, Pencil, UserPlus, UserMinus, Users, Save, X, RefreshCcw } from 'lucide-react';
import { ArrowLeft, Shield, Ban, CheckCircle, XCircle, Star, RotateCcw, Pencil, UserPlus, UserMinus, Users, Save, X } from 'lucide-react';
import Link from 'next/link';
export default function UserDetailPage() {
@ -100,18 +100,6 @@ export default function UserDetailPage() {
setActionLoading(false);
};
const handleResetFeedImpressions = async () => {
if (!confirm('Reset this user\'s feed impression history? They will see previously-seen posts again.')) return;
setActionLoading(true);
try {
const result = await api.resetFeedImpressions(params.id as string);
alert(`Feed impressions reset. ${result.deleted ?? 0} records cleared.`);
} catch (e: any) {
alert(`Reset failed: ${e.message}`);
}
setActionLoading(false);
};
return (
<AdminShell>
<Link href="/users" className="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-gray-700 mb-4">
@ -258,14 +246,6 @@ export default function UserDetailPage() {
</div>
)}
{/* Feed Impressions */}
<div>
<p className="text-xs font-medium text-gray-500 mb-2">Feed History</p>
<button onClick={handleResetFeedImpressions} className="btn-secondary text-xs py-1.5 flex items-center gap-1" disabled={actionLoading}>
<RefreshCcw className="w-3.5 h-3.5" /> Reset Feed Impressions
</button>
</div>
{/* View Posts */}
<div className="pt-2 border-t border-warm-300">
<Link href={`/posts?author_id=${user.id}`} className="text-brand-500 hover:text-brand-700 text-sm font-medium">
@ -276,11 +256,15 @@ export default function UserDetailPage() {
</div>
</div>
{/* Editable Profile */}
<OfficialProfileEditor user={user} onSaved={fetchUser} />
{/* Official Account: Editable Profile */}
{user.is_official && (
<OfficialProfileEditor user={user} onSaved={fetchUser} />
)}
{/* Follower/Following Management */}
<FollowManager userId={user.id} />
{/* Official Account: Follower/Following Management */}
{user.is_official && (
<FollowManager userId={user.id} />
)}
</div>
) : (
<div className="card p-8 text-center text-gray-500">User not found</div>
@ -407,7 +391,7 @@ function OfficialProfileEditor({ user, onSaved }: { user: any; onSaved: () => vo
<div className="card p-5">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold text-gray-700 flex items-center gap-2">
<Pencil className="w-4 h-4" /> Edit Profile
<Pencil className="w-4 h-4" /> Official Account Profile
</h3>
{!editing ? (
<button onClick={() => setEditing(true)} className="btn-secondary text-xs py-1 px-3">Edit</button>

View file

@ -1,250 +0,0 @@
'use client';
import AdminShell from '@/components/AdminShell';
import { api } from '@/lib/api';
import { formatDateTime } from '@/lib/utils';
import { useEffect, useState } from 'react';
import { Users, RefreshCw, CheckCircle, XCircle, Trash2, ChevronLeft, ChevronRight, Filter } from 'lucide-react';
const STATUS_COLORS: Record<string, string> = {
pending: 'bg-yellow-100 text-yellow-700',
approved: 'bg-green-100 text-green-700',
rejected: 'bg-red-100 text-red-700',
invited: 'bg-blue-100 text-blue-700',
};
export default function WaitlistPage() {
const [entries, setEntries] = useState<any[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [statusFilter, setStatusFilter] = useState('');
const [page, setPage] = useState(0);
const [actionLoading, setActionLoading] = useState<string | null>(null);
const [notesModal, setNotesModal] = useState<{ id: string; notes: string } | null>(null);
const limit = 50;
const fetchList = (p = page, status = statusFilter) => {
setLoading(true);
api.listWaitlist({ status: status || undefined, limit, offset: p * limit })
.then((data) => {
setEntries(data.entries || []);
setTotal(data.total || 0);
})
.catch(() => {})
.finally(() => setLoading(false));
};
useEffect(() => { fetchList(page, statusFilter); }, [page, statusFilter]);
const handleStatusChange = async (id: string, status: string) => {
setActionLoading(id + status);
try {
await api.updateWaitlist(id, { status });
fetchList();
} catch (e: any) {
alert(`Failed: ${e.message}`);
}
setActionLoading(null);
};
const handleDelete = async (id: string, email: string) => {
if (!confirm(`Delete waitlist entry for ${email}?`)) return;
setActionLoading(id + 'del');
try {
await api.deleteWaitlist(id);
fetchList();
} catch (e: any) {
alert(`Failed: ${e.message}`);
}
setActionLoading(null);
};
const handleSaveNotes = async () => {
if (!notesModal) return;
setActionLoading('notes');
try {
await api.updateWaitlist(notesModal.id, { notes: notesModal.notes });
setNotesModal(null);
fetchList();
} catch (e: any) {
alert(`Failed: ${e.message}`);
}
setActionLoading(null);
};
const totalPages = Math.max(1, Math.ceil(total / limit));
const counts: Record<string, number> = {};
entries.forEach((e) => { counts[e.status || 'pending'] = (counts[e.status || 'pending'] || 0) + 1; });
return (
<AdminShell>
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
<Users className="w-6 h-6 text-brand-500" /> Waitlist
</h1>
<p className="text-sm text-gray-500 mt-1">
{total} total {statusFilter ? `(filtered: ${statusFilter})` : ''}
</p>
</div>
<button onClick={() => fetchList()} className="btn-secondary text-sm flex items-center gap-1">
<RefreshCw className="w-4 h-4" /> Refresh
</button>
</div>
{/* Filter tabs */}
<div className="flex gap-2 mb-4">
{['', 'pending', 'approved', 'rejected', 'invited'].map((s) => (
<button
key={s}
onClick={() => { setStatusFilter(s); setPage(0); }}
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
statusFilter === s
? 'bg-brand-500 text-white'
: 'bg-warm-100 text-gray-600 hover:bg-warm-200'
}`}
>
{s === '' ? 'All' : s.charAt(0).toUpperCase() + s.slice(1)}
</button>
))}
</div>
<div className="card overflow-hidden">
{loading ? (
<div className="p-8 animate-pulse space-y-3">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="h-12 bg-warm-300 rounded" />
))}
</div>
) : entries.length === 0 ? (
<div className="p-8 text-center text-gray-400">
No waitlist entries{statusFilter ? ` with status "${statusFilter}"` : ''}.
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-warm-100 border-b border-warm-300">
<tr>
<th className="px-4 py-3 text-left font-medium text-gray-600">Email</th>
<th className="px-4 py-3 text-left font-medium text-gray-600">Name</th>
<th className="px-4 py-3 text-left font-medium text-gray-600">Referral</th>
<th className="px-4 py-3 text-left font-medium text-gray-600">Status</th>
<th className="px-4 py-3 text-left font-medium text-gray-600">Joined</th>
<th className="px-4 py-3 text-left font-medium text-gray-600">Notes</th>
<th className="px-4 py-3 text-left font-medium text-gray-600">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-warm-100">
{entries.map((e) => (
<tr key={e.id} className="hover:bg-warm-50">
<td className="px-4 py-2.5 font-medium text-gray-900">{e.email}</td>
<td className="px-4 py-2.5 text-gray-600">{e.name || '—'}</td>
<td className="px-4 py-2.5 text-gray-500 text-xs">
{e.referral_code ? <span className="font-mono bg-warm-100 px-1.5 py-0.5 rounded">{e.referral_code}</span> : '—'}
{e.invited_by && <span className="ml-1 text-gray-400">via {e.invited_by}</span>}
</td>
<td className="px-4 py-2.5">
<span className={`inline-flex px-2 py-0.5 rounded-full text-xs font-medium ${STATUS_COLORS[e.status || 'pending'] || 'bg-gray-100 text-gray-600'}`}>
{e.status || 'pending'}
</span>
</td>
<td className="px-4 py-2.5 text-gray-500 text-xs whitespace-nowrap">
{e.created_at ? formatDateTime(e.created_at) : '—'}
</td>
<td className="px-4 py-2.5 text-gray-500 text-xs max-w-[12rem] truncate" title={e.notes}>
<button
onClick={() => setNotesModal({ id: e.id, notes: e.notes || '' })}
className="text-brand-500 hover:underline"
>
{e.notes ? e.notes.slice(0, 30) + (e.notes.length > 30 ? '…' : '') : '+ add note'}
</button>
</td>
<td className="px-4 py-2.5">
<div className="flex items-center gap-1">
{e.status !== 'approved' && (
<button
onClick={() => handleStatusChange(e.id, 'approved')}
disabled={actionLoading === e.id + 'approved'}
title="Approve"
className="p-1.5 rounded hover:bg-green-50 text-green-600 disabled:opacity-40"
>
<CheckCircle className="w-4 h-4" />
</button>
)}
{e.status !== 'rejected' && (
<button
onClick={() => handleStatusChange(e.id, 'rejected')}
disabled={actionLoading === e.id + 'rejected'}
title="Reject"
className="p-1.5 rounded hover:bg-red-50 text-red-500 disabled:opacity-40"
>
<XCircle className="w-4 h-4" />
</button>
)}
<button
onClick={() => handleDelete(e.id, e.email)}
disabled={actionLoading === e.id + 'del'}
title="Delete"
className="p-1.5 rounded hover:bg-red-50 text-red-400 disabled:opacity-40"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between px-4 py-3 border-t border-warm-200">
<p className="text-xs text-gray-500">Page {page + 1} of {totalPages}</p>
<div className="flex items-center gap-2">
<button
onClick={() => setPage((p) => Math.max(0, p - 1))}
disabled={page === 0}
className="p-1.5 rounded border border-warm-300 disabled:opacity-40 hover:bg-warm-100"
>
<ChevronLeft className="w-4 h-4" />
</button>
<button
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
disabled={page >= totalPages - 1}
className="p-1.5 rounded border border-warm-300 disabled:opacity-40 hover:bg-warm-100"
>
<ChevronRight className="w-4 h-4" />
</button>
</div>
</div>
)}
</div>
{/* Notes Modal */}
{notesModal && (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={() => setNotesModal(null)}>
<div className="card p-5 w-full max-w-sm mx-4" onClick={(e) => e.stopPropagation()}>
<h3 className="text-sm font-semibold text-gray-800 mb-3">Admin Notes</h3>
<textarea
className="input w-full mb-3"
rows={4}
placeholder="Add notes about this applicant..."
value={notesModal.notes}
onChange={(e) => setNotesModal({ ...notesModal, notes: e.target.value })}
autoFocus
/>
<div className="flex gap-2 justify-end">
<button onClick={() => setNotesModal(null)} className="btn-secondary text-sm">Cancel</button>
<button onClick={handleSaveNotes} disabled={actionLoading === 'notes'} className="btn-primary text-sm">
{actionLoading === 'notes' ? 'Saving…' : 'Save Notes'}
</button>
</div>
</div>
</div>
)}
</AdminShell>
);
}

View file

@ -8,7 +8,7 @@ import {
LayoutDashboard, Users, FileText, Shield, ShieldCheck, Scale, Flag,
Settings, Activity, LogOut, ChevronLeft, ChevronRight, ChevronDown,
Sliders, FolderTree, HardDrive, AtSign, Brain, ScrollText, Wrench, Bot,
UserCog, ShieldAlert, Cog, Mail, MapPinned, Users2, Video, ClipboardList, Clock,
UserCog, ShieldAlert, Cog, Mail, MapPinned,
} from 'lucide-react';
import { useState } from 'react';
@ -31,8 +31,6 @@ const navigation: NavEntry[] = [
{ href: '/categories', label: 'Categories', icon: FolderTree },
{ href: '/neighborhoods', label: 'Neighborhoods', icon: MapPinned },
{ href: '/official-accounts', label: 'Official Accounts', icon: Bot },
{ href: '/groups', label: 'Groups & Capsules', icon: Users2 },
{ href: '/waitlist', label: 'Waitlist', icon: Clock },
],
},
{
@ -45,7 +43,6 @@ const navigation: NavEntry[] = [
{ href: '/ai-audit-log', label: 'AI Audit Log', icon: ScrollText },
{ href: '/appeals', label: 'Appeals', icon: Scale },
{ href: '/reports', label: 'Reports', icon: Flag },
{ href: '/capsule-reports', label: 'Capsule Reports', icon: ShieldAlert },
{ href: '/safe-links', label: 'Safe Links', icon: ShieldCheck },
{ href: '/content-tools', label: 'Content Tools', icon: Wrench },
],
@ -58,8 +55,6 @@ const navigation: NavEntry[] = [
{ href: '/usernames', label: 'Usernames', icon: AtSign },
{ href: '/storage', label: 'Storage', icon: HardDrive },
{ href: '/system', label: 'System Health', icon: Activity },
{ href: '/audit-log', label: 'Audit Log', icon: ClipboardList },
{ href: '/quips', label: 'Quip Repair', icon: Video },
{ href: '/settings/emails', label: 'Email Templates', icon: Mail },
{ href: '/settings', label: 'Settings', icon: Settings },
],

View file

@ -232,22 +232,6 @@ class ApiClient {
});
}
// Capsule Reports
async listCapsuleReports(params: { limit?: number; offset?: number; status?: string } = {}) {
const qs = new URLSearchParams();
if (params.limit) qs.set('limit', String(params.limit));
if (params.offset) qs.set('offset', String(params.offset));
if (params.status) qs.set('status', params.status);
return this.request<any>(`/api/v1/admin/capsule-reports?${qs}`);
}
async updateCapsuleReportStatus(id: string, status: string) {
return this.request<any>(`/api/v1/admin/capsule-reports/${id}`, {
method: 'PATCH',
body: JSON.stringify({ status }),
});
}
// Algorithm
async getAlgorithmConfig() {
return this.request<any>('/api/v1/admin/algorithm');
@ -649,77 +633,6 @@ class ApiClient {
body: JSON.stringify({ template_id: templateId, to_email: toEmail }),
});
}
// Groups admin
async listGroups(params: { search?: string; limit?: number; offset?: number } = {}) {
const qs = new URLSearchParams();
if (params.search) qs.set('search', params.search);
if (params.limit) qs.set('limit', String(params.limit));
if (params.offset) qs.set('offset', String(params.offset));
return this.request<any>(`/api/v1/admin/groups?${qs}`);
}
async getGroup(id: string) {
return this.request<any>(`/api/v1/admin/groups/${id}`);
}
async deleteGroup(id: string) {
return this.request<any>(`/api/v1/admin/groups/${id}`, { method: 'DELETE' });
}
async listGroupMembers(groupId: string) {
return this.request<any>(`/api/v1/admin/groups/${groupId}/members`);
}
async removeGroupMember(groupId: string, userId: string) {
return this.request<any>(`/api/v1/admin/groups/${groupId}/members/${userId}`, { method: 'DELETE' });
}
// Quip repair
async getBrokenQuips(limit = 50) {
return this.request<any>(`/api/v1/admin/quips/broken?limit=${limit}`);
}
async repairQuip(postId: string) {
return this.request<any>(`/api/v1/admin/quips/${postId}/repair`, { method: 'POST' });
}
async setPostThumbnail(postId: string, thumbnailUrl: string) {
return this.request<any>(`/api/v1/admin/posts/${postId}/thumbnail`, {
method: 'PATCH',
body: JSON.stringify({ thumbnail_url: thumbnailUrl }),
});
}
// Feed scores
async getFeedScores(limit = 50) {
return this.request<any>(`/api/v1/admin/feed-scores?limit=${limit}`);
}
// Waitlist
async listWaitlist(params: { status?: string; limit?: number; offset?: number } = {}) {
const qs = new URLSearchParams();
if (params.status) qs.set('status', params.status);
if (params.limit) qs.set('limit', String(params.limit));
if (params.offset) qs.set('offset', String(params.offset));
return this.request<any>(`/api/v1/admin/waitlist?${qs}`);
}
async updateWaitlist(id: string, data: { status?: string; notes?: string }) {
return this.request<any>(`/api/v1/admin/waitlist/${id}`, {
method: 'PATCH',
body: JSON.stringify(data),
});
}
async deleteWaitlist(id: string) {
return this.request<any>(`/api/v1/admin/waitlist/${id}`, { method: 'DELETE' });
}
// Feed impression reset
async resetFeedImpressions(userId: string) {
return this.request<any>(`/api/v1/admin/users/${userId}/feed-impressions`, { method: 'DELETE' });
}
}
export const api = new ApiClient();

View file

@ -1,56 +0,0 @@
package main
import (
"database/sql"
"fmt"
"log"
"os"
_ "github.com/lib/pq"
)
func main() {
dbURL := os.Getenv("DATABASE_URL")
if dbURL == "" {
log.Fatal("DATABASE_URL environment variable is not set")
}
db, err := sql.Open("postgres", dbURL)
if err != nil {
log.Fatal(err)
}
defer db.Close()
// Get column information for groups table
rows, err := db.Query(`
SELECT column_name, data_type
FROM information_schema.columns
WHERE table_name = 'groups'
ORDER BY ordinal_position;
`)
if err != nil {
log.Printf("Error querying columns: %v", err)
return
}
defer rows.Close()
fmt.Println("📋 Groups table columns:")
for rows.Next() {
var columnName, dataType string
err := rows.Scan(&columnName, &dataType)
if err != nil {
log.Printf("Error scanning row: %v", err)
continue
}
fmt.Printf(" - %s (%s)\n", columnName, dataType)
}
// Check if there's any data
var count int
err = db.QueryRow("SELECT COUNT(*) FROM groups").Scan(&count)
if err != nil {
log.Printf("Error counting groups: %v", err)
return
}
fmt.Printf("\n📊 Current groups count: %d\n", count)
}

View file

@ -1,224 +0,0 @@
package main
import (
"database/sql"
"fmt"
"log"
"os"
_ "github.com/lib/pq"
)
func main() {
dbURL := os.Getenv("DATABASE_URL")
if dbURL == "" {
log.Fatal("DATABASE_URL environment variable is not set")
}
db, err := sql.Open("postgres", dbURL)
if err != nil {
log.Fatal(err)
}
defer db.Close()
// Check if groups table exists
var exists bool
err = db.QueryRow(`
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_name = 'groups'
);
`).Scan(&exists)
if err != nil {
log.Printf("Error checking table: %v", err)
return
}
if !exists {
fmt.Println("❌ Groups table does not exist. Running migration...")
// Run the groups migration
migrationSQL := `
-- Groups System Database Schema
-- Creates tables for community groups, membership, join requests, and invitations
-- Main groups table
CREATE TABLE IF NOT EXISTS groups (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(50) NOT NULL,
description TEXT,
category VARCHAR(50) NOT NULL CHECK (category IN ('general', 'hobby', 'sports', 'professional', 'local_business', 'support', 'education')),
avatar_url TEXT,
banner_url TEXT,
is_private BOOLEAN DEFAULT FALSE,
created_by UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
member_count INTEGER DEFAULT 1,
post_count INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
UNIQUE(LOWER(name))
);
CREATE INDEX IF NOT EXISTS idx_groups_category ON groups(category);
CREATE INDEX IF NOT EXISTS idx_groups_created_by ON groups(created_by);
CREATE INDEX IF NOT EXISTS idx_groups_is_private ON groups(is_private);
CREATE INDEX IF NOT EXISTS idx_groups_member_count ON groups(member_count DESC);
-- Group members table with roles
CREATE TABLE IF NOT EXISTS group_members (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role VARCHAR(20) NOT NULL DEFAULT 'member' CHECK (role IN ('owner', 'admin', 'moderator', 'member')),
joined_at TIMESTAMP DEFAULT NOW(),
UNIQUE(group_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_group_members_group ON group_members(group_id);
CREATE INDEX IF NOT EXISTS idx_group_members_user ON group_members(user_id);
CREATE INDEX IF NOT EXISTS idx_group_members_role ON group_members(role);
-- Join requests for private groups
CREATE TABLE IF NOT EXISTS group_join_requests (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
status VARCHAR(20) NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'approved', 'rejected')),
message TEXT,
created_at TIMESTAMP DEFAULT NOW(),
reviewed_at TIMESTAMP,
reviewed_by UUID REFERENCES users(id),
UNIQUE(group_id, user_id, status)
);
CREATE INDEX IF NOT EXISTS idx_group_join_requests_group ON group_join_requests(group_id);
CREATE INDEX IF NOT EXISTS idx_group_join_requests_user ON group_join_requests(user_id);
CREATE INDEX IF NOT EXISTS idx_group_join_requests_status ON group_join_requests(status);
-- Group invitations (for future use)
CREATE TABLE IF NOT EXISTS group_invitations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
invited_by UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
invited_user UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
status VARCHAR(20) NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'accepted', 'rejected')),
message TEXT,
created_at TIMESTAMP DEFAULT NOW(),
responded_at TIMESTAMP,
UNIQUE(group_id, invited_user)
);
CREATE INDEX IF NOT EXISTS idx_group_invitations_group ON group_invitations(group_id);
CREATE INDEX IF NOT EXISTS idx_group_invitations_invited ON group_invitations(invited_user);
CREATE INDEX IF NOT EXISTS idx_group_invitations_status ON group_invitations(status);
-- Triggers for updating member and post counts
CREATE OR REPLACE FUNCTION update_group_member_count()
RETURNS TRIGGER AS $$
BEGIN
IF TG_OP = 'INSERT' THEN
UPDATE groups SET member_count = member_count + 1 WHERE id = NEW.group_id;
RETURN NEW;
ELSIF TG_OP = 'DELETE' THEN
UPDATE groups SET member_count = member_count - 1 WHERE id = OLD.group_id;
RETURN OLD;
END IF;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
CREATE OR REPLACE FUNCTION update_group_post_count()
RETURNS TRIGGER AS $$
BEGIN
IF TG_OP = 'INSERT' THEN
UPDATE groups SET post_count = post_count + 1 WHERE id = NEW.group_id;
RETURN NEW;
ELSIF TG_OP = 'DELETE' THEN
UPDATE groups SET post_count = post_count - 1 WHERE id = OLD.group_id;
RETURN OLD;
END IF;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
-- Create triggers
DROP TRIGGER IF EXISTS trigger_update_group_member_count ON group_members;
CREATE TRIGGER trigger_update_group_member_count
AFTER INSERT OR DELETE ON group_members
FOR EACH ROW EXECUTE FUNCTION update_group_member_count();
DROP TRIGGER IF EXISTS trigger_update_group_post_count ON posts;
CREATE TRIGGER trigger_update_group_post_count
AFTER INSERT OR DELETE ON posts
FOR EACH ROW EXECUTE FUNCTION update_group_post_count()
WHEN (NEW.group_id IS NOT NULL OR OLD.group_id IS NOT NULL);
-- Function to get suggested groups for a user
CREATE OR REPLACE FUNCTION get_suggested_groups(
p_user_id UUID,
p_limit INTEGER DEFAULT 10
)
RETURNS TABLE (
group_id UUID,
name VARCHAR,
description TEXT,
category VARCHAR,
is_private BOOLEAN,
member_count INTEGER,
post_count INTEGER,
reason TEXT
) AS $$
BEGIN
RETURN QUERY
WITH user_following AS (
SELECT followed_id FROM follows WHERE follower_id = p_user_id
),
user_categories AS (
SELECT DISTINCT category FROM user_category_settings WHERE user_id = p_user_id AND enabled = true
)
SELECT
g.id,
g.name,
g.description,
g.category,
g.is_private,
g.member_count,
g.post_count,
CASE
WHEN g.category IN (SELECT category FROM user_categories) THEN 'Based on your interests'
WHEN EXISTS(SELECT 1 FROM group_members gm WHERE gm.group_id = g.id AND gm.user_id IN (SELECT followed_id FROM user_following)) THEN 'Friends are members'
WHEN g.member_count > 100 THEN 'Popular community'
ELSE 'Growing community'
END as reason
FROM groups g
WHERE g.id NOT IN (
SELECT group_id FROM group_members WHERE user_id = p_user_id
)
AND g.is_private = false
ORDER BY
CASE
WHEN g.category IN (SELECT category FROM user_categories) THEN 1
WHEN EXISTS(SELECT 1 FROM group_members gm WHERE gm.group_id = g.id AND gm.user_id IN (SELECT followed_id FROM user_following)) THEN 2
ELSE 3
END,
g.member_count DESC
LIMIT p_limit;
END;
$$ LANGUAGE plpgsql;
`
_, err = db.Exec(migrationSQL)
if err != nil {
log.Printf("Error running migration: %v", err)
return
}
fmt.Println("✅ Groups migration completed successfully")
} else {
fmt.Println("✅ Groups table already exists")
}
// Now seed the data
fmt.Println("🌱 Seeding groups data...")
}

View file

@ -16,15 +16,14 @@ import (
"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"gitlab.com/patrickbritton3/sojorn/go-backend/internal/config"
"gitlab.com/patrickbritton3/sojorn/go-backend/internal/handlers"
"gitlab.com/patrickbritton3/sojorn/go-backend/internal/middleware"
"gitlab.com/patrickbritton3/sojorn/go-backend/internal/monitoring"
"gitlab.com/patrickbritton3/sojorn/go-backend/internal/realtime"
"gitlab.com/patrickbritton3/sojorn/go-backend/internal/repository"
"gitlab.com/patrickbritton3/sojorn/go-backend/internal/services"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
)
func main() {
@ -170,7 +169,7 @@ func main() {
linkPreviewService := services.NewLinkPreviewService(dbPool, s3Client, cfg.R2MediaBucket, cfg.R2ImgDomain)
userHandler := handlers.NewUserHandler(userRepo, postRepo, notificationService, assetService)
postHandler := handlers.NewPostHandler(postRepo, userRepo, feedService, assetService, notificationService, moderationService, contentFilter, openRouterService, linkPreviewService, localAIService, s3Client, cfg.R2VideoBucket, cfg.R2VidDomain)
postHandler := handlers.NewPostHandler(postRepo, userRepo, feedService, assetService, notificationService, moderationService, contentFilter, openRouterService, linkPreviewService, localAIService)
chatHandler := handlers.NewChatHandler(chatRepo, notificationService, hub)
authHandler := handlers.NewAuthHandler(userRepo, cfg, emailService, sendPulseService)
categoryHandler := handlers.NewCategoryHandler(categoryRepo)
@ -187,7 +186,7 @@ func main() {
moderationHandler := handlers.NewModerationHandler(moderationService, openRouterService, localAIService)
adminHandler := handlers.NewAdminHandler(dbPool, moderationService, appealService, emailService, openRouterService, azureOpenAIService, officialAccountsService, linkPreviewService, localAIService, cfg.JWTSecret, s3Client, cfg.R2MediaBucket, cfg.R2VideoBucket, cfg.R2ImgDomain, cfg.R2VidDomain)
adminHandler := handlers.NewAdminHandler(dbPool, moderationService, appealService, emailService, openRouterService, azureOpenAIService, officialAccountsService, linkPreviewService, localAIService, cfg.JWTSecret, cfg.TurnstileSecretKey, s3Client, cfg.R2MediaBucket, cfg.R2VideoBucket, cfg.R2ImgDomain, cfg.R2VidDomain)
accountHandler := handlers.NewAccountHandler(userRepo, emailService, cfg)
@ -214,19 +213,6 @@ func main() {
cfg.R2VidDomain,
)
// Feed algorithm service (scores posts for ranked feed)
feedAlgorithmService := services.NewFeedAlgorithmService(dbPool)
// Health check service
hcService := monitoring.NewHealthCheckService(dbPool)
// Repost & profile layout handlers
repostHandler := handlers.NewRepostHandler(dbPool)
profileLayoutHandler := handlers.NewProfileLayoutHandler(dbPool)
// Audio library proxy (Funkwhale — gracefully returns 503 until FUNKWHALE_BASE is set)
audioHandler := handlers.NewAudioHandler(cfg.FunkwhaleBase)
r.GET("/ws", wsHandler.ServeWS)
r.GET("/health", func(c *gin.Context) {
@ -235,9 +221,6 @@ func main() {
r.HEAD("/health", func(c *gin.Context) {
c.Status(200)
})
r.GET("/health/detailed", gin.WrapF(hcService.HealthCheckHandler))
r.GET("/health/ready", gin.WrapF(hcService.ReadinessHandler))
r.GET("/health/live", gin.WrapF(hcService.LivenessHandler))
// ALTCHA challenge endpoints (direct to main router for testing)
r.GET("/api/v1/auth/altcha-challenge", authHandler.GetAltchaChallenge)
@ -285,9 +268,6 @@ func main() {
auth.POST("/reset-password", authHandler.ResetPassword)
}
// Image proxy — public (no auth needed so CachedNetworkImage can fetch without Bearer token)
v1.GET("/image-proxy", mediaHandler.ImageProxy)
authorized := v1.Group("")
authorized.Use(middleware.AuthMiddleware(cfg.JWTSecret, dbPool))
{
@ -321,7 +301,6 @@ func main() {
users.GET("/blocked", userHandler.GetBlockedUsers)
users.POST("/report", userHandler.ReportUser)
users.POST("/block_by_handle", userHandler.BlockUserByHandle)
users.POST("/me/blocks/bulk", userHandler.BulkBlockUsers)
// Social Graph: Followers & Following
users.GET("/:id/followers", userHandler.GetFollowers)
@ -346,7 +325,6 @@ func main() {
authorized.DELETE("/posts/:id", postHandler.DeletePost)
authorized.POST("/posts/:id/pin", postHandler.PinPost)
authorized.PATCH("/posts/:id/visibility", postHandler.UpdateVisibility)
authorized.POST("/posts/:id/hide", postHandler.HidePost)
authorized.POST("/posts/:id/like", postHandler.LikePost)
authorized.DELETE("/posts/:id/like", postHandler.UnlikePost)
authorized.POST("/posts/:id/save", postHandler.SavePost)
@ -354,7 +332,6 @@ func main() {
authorized.POST("/posts/:id/reactions/toggle", postHandler.ToggleReaction)
authorized.POST("/posts/:id/comments", postHandler.CreateComment)
authorized.GET("/feed", postHandler.GetFeed)
authorized.GET("/feed/personal", postHandler.GetFeed)
authorized.POST("/beacons", postHandler.CreateBeacon)
authorized.GET("/beacons/nearby", postHandler.GetNearbyBeacons)
authorized.POST("/beacons/:id/vouch", postHandler.VouchBeacon)
@ -406,7 +383,7 @@ func main() {
// Media routes
authorized.POST("/upload", mediaHandler.Upload)
authorized.GET("/media/sign", mediaHandler.GetSignedMediaURL)
// Search & Discover routes
discoverHandler := handlers.NewDiscoverHandler(userRepo, postRepo, tagRepo, categoryRepo, assetService)
authorized.GET("/search", discoverHandler.Search)
@ -417,16 +394,6 @@ func main() {
authorized.POST("/hashtags/:name/follow", discoverHandler.FollowHashtag)
authorized.DELETE("/hashtags/:name/follow", discoverHandler.UnfollowHashtag)
// User by-handle lookup (used by capsule invite to resolve public keys)
authorized.GET("/users/by-handle/:handle", userHandler.GetUserByHandle)
// Follow System (unique routes only — followers/following covered by users group above)
followHandler := handlers.NewFollowHandler(dbPool)
authorized.POST("/users/:id/unfollow", followHandler.UnfollowUser)
authorized.GET("/users/:id/is-following", followHandler.IsFollowing)
authorized.GET("/users/:id/mutual-followers", followHandler.GetMutualFollowers)
authorized.GET("/users/suggested", followHandler.GetSuggestedUsers)
// Notifications
notificationHandler := handlers.NewNotificationHandler(notifRepo, notificationService)
authorized.GET("/notifications", notificationHandler.GetNotifications)
@ -493,45 +460,18 @@ func main() {
neighborhoods.GET("/mine", neighborhoodHandler.GetMyNeighborhood)
}
// Groups system (community groups with discovery and membership)
groupsHandler := handlers.NewGroupsHandler(dbPool)
groups := authorized.Group("/groups")
{
groups.GET("", groupsHandler.ListGroups) // List all groups with optional category filter
groups.GET("/mine", groupsHandler.GetMyGroups) // Get user's joined groups
groups.GET("/suggested", groupsHandler.GetSuggestedGroups) // Get suggested groups
groups.POST("", groupsHandler.CreateGroup) // Create new group
groups.GET("/:id", groupsHandler.GetGroup) // Get group details
groups.POST("/:id/join", groupsHandler.JoinGroup) // Join group or request to join
groups.POST("/:id/leave", groupsHandler.LeaveGroup) // Leave group
groups.GET("/:id/members", groupsHandler.GetGroupMembers) // Get group members
groups.GET("/:id/requests", groupsHandler.GetPendingRequests) // Get pending join requests (admin)
groups.POST("/:id/requests/:requestId/approve", groupsHandler.ApproveJoinRequest) // Approve join request
groups.POST("/:id/requests/:requestId/reject", groupsHandler.RejectJoinRequest) // Reject join request
groups.GET("/:id/feed", groupsHandler.GetGroupFeed)
groups.GET("/:id/key-status", groupsHandler.GetGroupKeyStatus)
groups.POST("/:id/keys", groupsHandler.DistributeGroupKeys)
groups.GET("/:id/members/public-keys", groupsHandler.GetGroupMemberPublicKeys)
groups.POST("/:id/invite-member", groupsHandler.InviteMember)
groups.DELETE("/:id/members/:userId", groupsHandler.RemoveMember)
groups.PATCH("/:id/settings", groupsHandler.UpdateGroupSettings)
}
// Capsule system (E2EE groups + clusters)
capsules := authorized.Group("/capsules")
{
capsules.GET("/mine", capsuleHandler.ListMyGroups)
capsules.GET("/public", capsuleHandler.ListPublicClusters)
capsules.GET("/discover", capsuleHandler.DiscoverGroups)
capsules.POST("", capsuleHandler.CreateCapsule)
capsules.POST("/group", capsuleHandler.CreateGroup)
capsules.POST("/:id/join", capsuleHandler.JoinGroup)
capsules.GET("/:id", capsuleHandler.GetCapsule)
capsules.POST("/:id/entries", capsuleHandler.PostCapsuleEntry)
capsules.GET("/:id/entries", capsuleHandler.GetCapsuleEntries)
capsules.POST("/:id/invite", capsuleHandler.InviteToCapsule)
capsules.POST("/:id/rotate-keys", capsuleHandler.RotateKeys)
capsules.POST("/:id/entries/:entryId/report", capsuleHandler.ReportCapsuleEntry)
// Group features (posts, chat, forum, members)
capsules.GET("/:id/posts", groupHandler.ListGroupPosts)
@ -573,28 +513,6 @@ func main() {
escrow.DELETE("/backup", capsuleEscrowHandler.DeleteBackup)
}
// Repost & amplification system
authorized.POST("/posts/repost", repostHandler.CreateRepost)
authorized.POST("/posts/boost", repostHandler.BoostPost)
authorized.GET("/posts/trending", repostHandler.GetTrendingPosts)
authorized.GET("/posts/:id/reposts", repostHandler.GetRepostsForPost)
authorized.GET("/posts/:id/amplification", repostHandler.GetAmplificationAnalytics)
authorized.POST("/posts/:id/calculate-score", repostHandler.CalculateAmplificationScore)
authorized.DELETE("/reposts/:id", repostHandler.DeleteRepost)
authorized.POST("/reposts/:id/report", repostHandler.ReportRepost)
authorized.GET("/amplification/rules", repostHandler.GetAmplificationRules)
authorized.GET("/users/:id/reposts", repostHandler.GetUserReposts)
authorized.GET("/users/:id/can-boost/:postId", repostHandler.CanBoostPost)
authorized.GET("/users/:id/daily-boosts", repostHandler.GetDailyBoostCount)
// Profile widget layout
authorized.GET("/profile/layout", profileLayoutHandler.GetProfileLayout)
authorized.PUT("/profile/layout", profileLayoutHandler.SaveProfileLayout)
// Audio library (Funkwhale proxy — returns 503 until FUNKWHALE_BASE is set in env)
authorized.GET("/audio/library", audioHandler.SearchAudioLibrary)
authorized.GET("/audio/library/:trackId/listen", audioHandler.GetAudioTrackListen)
}
}
@ -647,10 +565,6 @@ func main() {
admin.PATCH("/reports/:id", adminHandler.UpdateReportStatus)
admin.POST("/reports/bulk", adminHandler.BulkUpdateReports)
// Capsule (encrypted group) reports
admin.GET("/capsule-reports", adminHandler.ListCapsuleReports)
admin.PATCH("/capsule-reports/:id", adminHandler.UpdateCapsuleReportStatus)
// Algorithm / Feed Config
admin.GET("/algorithm", adminHandler.GetAlgorithmConfig)
admin.PUT("/algorithm", adminHandler.UpdateAlgorithmConfig)
@ -734,29 +648,6 @@ func main() {
admin.GET("/email-templates/:id", adminHandler.GetEmailTemplate)
admin.PATCH("/email-templates/:id", adminHandler.UpdateEmailTemplate)
admin.POST("/email-templates/test", adminHandler.SendTestEmail)
// Groups admin
admin.GET("/groups", adminHandler.AdminListGroups)
admin.GET("/groups/:id", adminHandler.AdminGetGroup)
admin.DELETE("/groups/:id", adminHandler.AdminDeleteGroup)
admin.GET("/groups/:id/members", adminHandler.AdminListGroupMembers)
admin.DELETE("/groups/:id/members/:userId", adminHandler.AdminRemoveGroupMember)
// Quip repair
admin.GET("/quips/broken", adminHandler.GetBrokenQuips)
admin.PATCH("/posts/:id/thumbnail", adminHandler.SetPostThumbnail)
admin.POST("/quips/:id/repair", adminHandler.RepairQuip)
// Feed scores viewer
admin.GET("/feed-scores", adminHandler.AdminGetFeedScores)
// Waitlist management
admin.GET("/waitlist", adminHandler.AdminListWaitlist)
admin.PATCH("/waitlist/:id", adminHandler.AdminUpdateWaitlist)
admin.DELETE("/waitlist/:id", adminHandler.AdminDeleteWaitlist)
// Feed impression reset
admin.DELETE("/users/:id/feed-impressions", adminHandler.AdminResetFeedImpressions)
}
// Public claim request endpoint (no auth)
@ -776,18 +667,6 @@ func main() {
}
}()
// Background job: update feed algorithm scores every 15 minutes
go func() {
ticker := time.NewTicker(15 * time.Minute)
defer ticker.Stop()
for range ticker.C {
log.Debug().Msg("[FeedAlgorithm] Refreshing feed scores")
if err := feedAlgorithmService.UpdateFeedScores(context.Background(), []string{}, ""); err != nil {
log.Error().Err(err).Msg("[FeedAlgorithm] Failed to refresh feed scores")
}
}
}()
// Background job: purge accounts past 14-day deletion window (runs every hour)
go func() {
ticker := time.NewTicker(1 * time.Hour)

View file

@ -0,0 +1,157 @@
package main
import (
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/joho/godotenv"
)
type SupabaseProfile struct {
ID string `json:"id"`
Handle string `json:"handle"`
DisplayName string `json:"display_name"`
Bio *string `json:"bio"`
AvatarURL *string `json:"avatar_url"`
IsOfficial bool `json:"is_official"`
IsPrivate bool `json:"is_private"`
BeaconEnabled bool `json:"beacon_enabled"`
CreatedAt time.Time `json:"created_at"`
}
type SupabasePost struct {
ID string `json:"id"`
AuthorID string `json:"author_id"`
Body string `json:"body"`
ImageURL *string `json:"image_url"`
CreatedAt time.Time `json:"created_at"`
CategoryID *string `json:"category_id"`
Status string `json:"status"`
Visibility string `json:"visibility"`
}
func main() {
godotenv.Load()
dbURL := os.Getenv("DATABASE_URL")
sbURL := os.Getenv("SUPABASE_URL")
sbKey := os.Getenv("SUPABASE_KEY")
if dbURL == "" || sbURL == "" || sbKey == "" {
log.Fatal("Missing env vars: DATABASE_URL, SUPABASE_URL, or SUPABASE_KEY")
}
// Connect to Local DB
pool, err := pgxpool.New(context.Background(), dbURL)
if err != nil {
log.Fatal(err)
}
defer pool.Close()
ctx := context.Background()
// 1. Fetch Profiles
log.Println("Fetching profiles from Supabase...")
var profiles []SupabaseProfile
if err := fetchSupabase(sbURL, sbKey, "profiles", &profiles); err != nil {
log.Fatal(err)
}
log.Printf("Found %d profiles", len(profiles))
// 2. Insert Profiles (and Users if needed)
for _, p := range profiles {
// Ensure User Exists
var exists bool
pool.QueryRow(ctx, "SELECT EXISTS(SELECT 1 FROM users WHERE id = $1)", p.ID).Scan(&exists)
if !exists {
// Create placeholder user
placeholderEmail := fmt.Sprintf("imported_%s@sojorn.com", p.ID[:8])
_, err := pool.Exec(ctx, `
INSERT INTO users (id, email, encrypted_password, created_at)
VALUES ($1, $2, 'placeholder_hash', $3)
`, p.ID, placeholderEmail, p.CreatedAt)
if err != nil {
log.Printf("Failed to create user for profile %s: %v", p.Handle, err)
continue
}
}
// Upsert Profile
_, err := pool.Exec(ctx, `
INSERT INTO profiles (id, handle, display_name, bio, avatar_url, is_official, is_private, beacon_enabled, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW())
ON CONFLICT (id) DO UPDATE SET
handle = EXCLUDED.handle,
display_name = EXCLUDED.display_name,
bio = EXCLUDED.bio,
avatar_url = EXCLUDED.avatar_url,
is_private = EXCLUDED.is_private
`, p.ID, p.Handle, p.DisplayName, p.Bio, p.AvatarURL, p.IsOfficial, p.IsPrivate, p.BeaconEnabled, p.CreatedAt)
if err != nil {
log.Printf("Failed to import profile %s: %v", p.Handle, err)
}
}
// 3. Fetch Posts
log.Println("Fetching posts from Supabase...")
var posts []SupabasePost
if err := fetchSupabase(sbURL, sbKey, "posts", &posts); err != nil {
log.Fatal(err)
}
log.Printf("Found %d posts", len(posts))
// 4. Insert Posts
for _, p := range posts {
// Default values if missing
status := "active"
if p.Status != "" {
status = p.Status
}
visibility := "public"
if p.Visibility != "" {
visibility = p.Visibility
}
_, err := pool.Exec(ctx, `
INSERT INTO posts (id, author_id, body, image_url, category_id, status, visibility, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT (id) DO NOTHING
`, p.ID, p.AuthorID, p.Body, p.ImageURL, p.CategoryID, status, visibility, p.CreatedAt)
if err != nil {
log.Printf("Failed to import post %s: %v", p.ID, err)
}
}
log.Println("Migration complete.")
}
func fetchSupabase(url, key, table string, target interface{}) error {
req, err := http.NewRequest("GET", fmt.Sprintf("%s/rest/v1/%s?select=*", url, table), nil)
if err != nil {
return err
}
req.Header.Add("apikey", key)
req.Header.Add("Authorization", "Bearer "+key)
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("supabase API error (%d): %s", resp.StatusCode, string(body))
}
return json.NewDecoder(resp.Body).Decode(target)
}

View file

@ -41,7 +41,6 @@ require (
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.53.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0 // indirect
github.com/MicahParks/keyfunc v1.9.0 // indirect
github.com/altcha-org/altcha-lib-go v1.0.0 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 // indirect

View file

@ -34,8 +34,6 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapp
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0/go.mod h1:cSgYe11MCNYunTnRXrKiR/tHc0eoKjICUuWpNZoVCOo=
github.com/MicahParks/keyfunc v1.9.0 h1:lhKd5xrFHLNOWrDc4Tyb/Q1AJ4LCzQ48GVJyVIID3+o=
github.com/MicahParks/keyfunc v1.9.0/go.mod h1:IdnCilugA0O/99dW+/MkvlyrsX8+L8+x95xuVNtM5jw=
github.com/altcha-org/altcha-lib-go v1.0.0 h1:7oPti0aUS+YCep8nwt5b9g4jYfCU55ZruWESL8G9K5M=
github.com/altcha-org/altcha-lib-go v1.0.0/go.mod h1:I8ESLVWR9C58uvGufB/AJDPhaSU4+4Oh3DLpVtgwDAk=
github.com/aws/aws-sdk-go-v2 v1.41.1 h1:ABlyEARCDLN034NhxlRUSZr4l71mh+T5KAeGh6cerhU=
github.com/aws/aws-sdk-go-v2 v1.41.1/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 h1:489krEF9xIGkOaaX3CE/Be2uWjiXrkCH6gUX+bZA/BU=

View file

@ -35,6 +35,7 @@ type Config struct {
R2SecretKey string
R2MediaBucket string
R2VideoBucket string
TurnstileSecretKey string
APIBaseURL string
AppBaseURL string
OpenRouterAPIKey string
@ -43,7 +44,6 @@ type Config struct {
AzureOpenAIAPIKey string
AzureOpenAIEndpoint string
AzureOpenAIAPIVersion string
FunkwhaleBase string // e.g. "http://localhost:5001" — empty means not yet deployed
}
func LoadConfig() *Config {
@ -85,6 +85,7 @@ func LoadConfig() *Config {
R2SecretKey: getEnv("R2_SECRET_KEY", ""),
R2MediaBucket: getEnv("R2_MEDIA_BUCKET", "sojorn-media"),
R2VideoBucket: getEnv("R2_VIDEO_BUCKET", "sojorn-videos"),
TurnstileSecretKey: getEnv("TURNSTILE_SECRET", ""),
APIBaseURL: getEnv("API_BASE_URL", "https://api.sojorn.net"),
AppBaseURL: getEnv("APP_BASE_URL", "https://mp.ls"),
OpenRouterAPIKey: getEnv("OPENROUTER_API", ""),
@ -93,7 +94,6 @@ func LoadConfig() *Config {
AzureOpenAIAPIKey: getEnv("AZURE_OPENAI_API_KEY", ""),
AzureOpenAIEndpoint: getEnv("AZURE_OPENAI_ENDPOINT", ""),
AzureOpenAIAPIVersion: getEnv("AZURE_OPENAI_API_VERSION", "2024-02-15-preview"),
FunkwhaleBase: getEnv("FUNKWHALE_BASE", ""),
}
}

View file

@ -18,8 +18,8 @@ import (
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/rs/zerolog/log"
"gitlab.com/patrickbritton3/sojorn/go-backend/internal/services"
"github.com/rs/zerolog/log"
"golang.org/x/crypto/bcrypt"
)
@ -34,6 +34,7 @@ type AdminHandler struct {
linkPreviewService *services.LinkPreviewService
localAIService *services.LocalAIService
jwtSecret string
turnstileSecret string
s3Client *s3.Client
mediaBucket string
videoBucket string
@ -41,7 +42,7 @@ type AdminHandler struct {
vidDomain string
}
func NewAdminHandler(pool *pgxpool.Pool, moderationService *services.ModerationService, appealService *services.AppealService, emailService *services.EmailService, openRouterService *services.OpenRouterService, azureOpenAIService *services.AzureOpenAIService, officialAccountsService *services.OfficialAccountsService, linkPreviewService *services.LinkPreviewService, localAIService *services.LocalAIService, jwtSecret string, s3Client *s3.Client, mediaBucket string, videoBucket string, imgDomain string, vidDomain string) *AdminHandler {
func NewAdminHandler(pool *pgxpool.Pool, moderationService *services.ModerationService, appealService *services.AppealService, emailService *services.EmailService, openRouterService *services.OpenRouterService, azureOpenAIService *services.AzureOpenAIService, officialAccountsService *services.OfficialAccountsService, linkPreviewService *services.LinkPreviewService, localAIService *services.LocalAIService, jwtSecret string, turnstileSecret string, s3Client *s3.Client, mediaBucket string, videoBucket string, imgDomain string, vidDomain string) *AdminHandler {
return &AdminHandler{
pool: pool,
moderationService: moderationService,
@ -53,6 +54,7 @@ func NewAdminHandler(pool *pgxpool.Pool, moderationService *services.ModerationS
linkPreviewService: linkPreviewService,
localAIService: localAIService,
jwtSecret: jwtSecret,
turnstileSecret: turnstileSecret,
s3Client: s3Client,
mediaBucket: mediaBucket,
videoBucket: videoBucket,
@ -1726,89 +1728,6 @@ func (h *AdminHandler) UpdateReportStatus(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "Report updated"})
}
// ──────────────────────────────────────────────
// Capsule Reports
// ──────────────────────────────────────────────
func (h *AdminHandler) ListCapsuleReports(c *gin.Context) {
ctx := c.Request.Context()
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
statusFilter := c.DefaultQuery("status", "pending")
rows, err := h.pool.Query(ctx, `
SELECT cr.id, cr.reporter_id, cr.capsule_id, cr.entry_id,
cr.decrypted_sample, cr.reason, cr.status, cr.created_at,
g.name AS capsule_name,
p.handle AS reporter_handle
FROM capsule_reports cr
JOIN groups g ON cr.capsule_id = g.id
JOIN profiles p ON cr.reporter_id = p.id
WHERE cr.status = $1
ORDER BY cr.created_at ASC
LIMIT $2 OFFSET $3
`, statusFilter, limit, offset)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list capsule reports"})
return
}
defer rows.Close()
var total int
h.pool.QueryRow(ctx, `SELECT COUNT(*) FROM capsule_reports WHERE status = $1`, statusFilter).Scan(&total)
var reports []gin.H
for rows.Next() {
var rID, reporterID, capsuleID, entryID uuid.UUID
var decryptedSample *string
var reason, status, capsuleName, reporterHandle string
var createdAt time.Time
if err := rows.Scan(&rID, &reporterID, &capsuleID, &entryID,
&decryptedSample, &reason, &status, &createdAt,
&capsuleName, &reporterHandle); err != nil {
continue
}
reports = append(reports, gin.H{
"id": rID, "reporter_id": reporterID,
"capsule_id": capsuleID, "capsule_name": capsuleName,
"entry_id": entryID, "decrypted_sample": decryptedSample,
"reason": reason, "status": status,
"created_at": createdAt, "reporter_handle": reporterHandle,
})
}
if reports == nil {
reports = []gin.H{}
}
c.JSON(http.StatusOK, gin.H{"reports": reports, "total": total, "limit": limit, "offset": offset})
}
func (h *AdminHandler) UpdateCapsuleReportStatus(c *gin.Context) {
ctx := c.Request.Context()
reportID := c.Param("id")
var req struct {
Status string `json:"status" binding:"required,oneof=reviewed dismissed actioned"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
_, err := h.pool.Exec(ctx,
`UPDATE capsule_reports SET status = $1, updated_at = NOW() WHERE id = $2::uuid`,
req.Status, reportID,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update report"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Report updated"})
}
// ──────────────────────────────────────────────
// Algorithm / Feed Settings
// ──────────────────────────────────────────────
@ -1829,10 +1748,6 @@ func (h *AdminHandler) GetAlgorithmConfig(c *gin.Context) {
{"key": "feed_engagement_weight", "value": "0.3", "description": "Weight for engagement metrics"},
{"key": "feed_harmony_weight", "value": "0.2", "description": "Weight for author harmony score"},
{"key": "feed_diversity_weight", "value": "0.1", "description": "Weight for content diversity"},
{"key": "feed_cooling_multiplier", "value": "0.2", "description": "Score multiplier for previously-seen posts (01, lower = stronger penalty)"},
{"key": "feed_diversity_personal_pct", "value": "60", "description": "% of feed from top personal scores"},
{"key": "feed_diversity_category_pct", "value": "20", "description": "% of feed from under-represented categories"},
{"key": "feed_diversity_discovery_pct", "value": "20", "description": "% of feed from authors viewer doesn't follow"},
{"key": "moderation_auto_flag_threshold", "value": "0.7", "description": "AI score threshold for auto-flagging"},
{"key": "moderation_auto_remove_threshold", "value": "0.95", "description": "AI score threshold for auto-removal"},
},
@ -4225,449 +4140,3 @@ func (h *AdminHandler) GetAltchaChallenge(c *gin.Context) {
c.JSON(http.StatusOK, challenge)
}
// ──────────────────────────────────────────────────────────────────────────────
// Groups admin
// ──────────────────────────────────────────────────────────────────────────────
// AdminListGroups GET /admin/groups?search=&limit=50&offset=0
func (h *AdminHandler) AdminListGroups(c *gin.Context) {
search := c.Query("search")
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
if limit <= 0 || limit > 200 {
limit = 50
}
query := `
SELECT g.id, g.name, g.description, g.is_private, g.status,
g.created_at, g.key_version, g.key_rotation_needed,
COUNT(DISTINCT gm.user_id) AS member_count,
COUNT(DISTINCT gp.post_id) AS post_count
FROM groups g
LEFT JOIN group_members gm ON gm.group_id = g.id
LEFT JOIN group_posts gp ON gp.group_id = g.id
`
args := []interface{}{}
if search != "" {
query += " WHERE g.name ILIKE $1 OR g.description ILIKE $1"
args = append(args, "%"+search+"%")
}
query += fmt.Sprintf(`
GROUP BY g.id
ORDER BY g.created_at DESC
LIMIT $%d OFFSET $%d`, len(args)+1, len(args)+2)
args = append(args, limit, offset)
rows, err := h.pool.Query(c.Request.Context(), query, args...)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
defer rows.Close()
type groupRow struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
IsPrivate bool `json:"is_private"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
KeyVersion int `json:"key_version"`
KeyRotationNeeded bool `json:"key_rotation_needed"`
MemberCount int `json:"member_count"`
PostCount int `json:"post_count"`
}
var groups []groupRow
for rows.Next() {
var g groupRow
if err := rows.Scan(&g.ID, &g.Name, &g.Description, &g.IsPrivate, &g.Status,
&g.CreatedAt, &g.KeyVersion, &g.KeyRotationNeeded, &g.MemberCount, &g.PostCount); err != nil {
continue
}
groups = append(groups, g)
}
c.JSON(http.StatusOK, gin.H{"groups": groups, "limit": limit, "offset": offset})
}
// AdminGetGroup GET /admin/groups/:id
func (h *AdminHandler) AdminGetGroup(c *gin.Context) {
groupID := c.Param("id")
row := h.pool.QueryRow(c.Request.Context(), `
SELECT g.id, g.name, g.description, g.is_private, g.status, g.created_at,
g.key_version, g.key_rotation_needed,
COUNT(DISTINCT gm.user_id) AS member_count,
COUNT(DISTINCT gp.post_id) AS post_count
FROM groups g
LEFT JOIN group_members gm ON gm.group_id = g.id
LEFT JOIN group_posts gp ON gp.group_id = g.id
WHERE g.id = $1
GROUP BY g.id
`, groupID)
var g struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
IsPrivate bool `json:"is_private"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
KeyVersion int `json:"key_version"`
KeyRotationNeeded bool `json:"key_rotation_needed"`
MemberCount int `json:"member_count"`
PostCount int `json:"post_count"`
}
if err := row.Scan(&g.ID, &g.Name, &g.Description, &g.IsPrivate, &g.Status, &g.CreatedAt,
&g.KeyVersion, &g.KeyRotationNeeded, &g.MemberCount, &g.PostCount); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "group not found"})
return
}
c.JSON(http.StatusOK, g)
}
// AdminDeleteGroup DELETE /admin/groups/:id (soft delete)
func (h *AdminHandler) AdminDeleteGroup(c *gin.Context) {
groupID := c.Param("id")
_, err := h.pool.Exec(c.Request.Context(),
`UPDATE groups SET status = 'inactive' WHERE id = $1`, groupID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "group deactivated"})
}
// AdminListGroupMembers GET /admin/groups/:id/members
func (h *AdminHandler) AdminListGroupMembers(c *gin.Context) {
groupID := c.Param("id")
rows, err := h.pool.Query(c.Request.Context(), `
SELECT gm.user_id, u.username, u.display_name, gm.role, gm.joined_at
FROM group_members gm
JOIN users u ON u.id = gm.user_id
WHERE gm.group_id = $1
ORDER BY gm.joined_at
`, groupID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
defer rows.Close()
type member struct {
UserID string `json:"user_id"`
Username string `json:"username"`
DisplayName string `json:"display_name"`
Role string `json:"role"`
JoinedAt time.Time `json:"joined_at"`
}
var members []member
for rows.Next() {
var m member
if err := rows.Scan(&m.UserID, &m.Username, &m.DisplayName, &m.Role, &m.JoinedAt); err != nil {
continue
}
members = append(members, m)
}
c.JSON(http.StatusOK, gin.H{"members": members})
}
// AdminRemoveGroupMember DELETE /admin/groups/:id/members/:userId
func (h *AdminHandler) AdminRemoveGroupMember(c *gin.Context) {
groupID := c.Param("id")
userID := c.Param("userId")
_, err := h.pool.Exec(c.Request.Context(),
`DELETE FROM group_members WHERE group_id = $1 AND user_id = $2`, groupID, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Flag group for key rotation (client will auto-rotate on next open)
h.pool.Exec(c.Request.Context(),
`UPDATE groups SET key_rotation_needed = true WHERE id = $1`, groupID)
c.JSON(http.StatusOK, gin.H{"message": "member removed"})
}
// ──────────────────────────────────────────────────────────────────────────────
// Quip (video post) repair
// ──────────────────────────────────────────────────────────────────────────────
// GetBrokenQuips GET /admin/quips/broken
// Returns posts that have a video_url but are missing a thumbnail.
func (h *AdminHandler) GetBrokenQuips(c *gin.Context) {
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
if limit <= 0 || limit > 200 {
limit = 50
}
rows, err := h.pool.Query(c.Request.Context(), `
SELECT id, user_id, video_url, created_at
FROM posts
WHERE video_url IS NOT NULL
AND (thumbnail_url IS NULL OR thumbnail_url = '')
AND status = 'active'
ORDER BY created_at DESC
LIMIT $1
`, limit)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
defer rows.Close()
type quip struct {
ID string `json:"id"`
UserID string `json:"user_id"`
VideoURL string `json:"video_url"`
CreatedAt time.Time `json:"created_at"`
}
var quips []quip
for rows.Next() {
var q quip
if err := rows.Scan(&q.ID, &q.UserID, &q.VideoURL, &q.CreatedAt); err != nil {
continue
}
quips = append(quips, q)
}
c.JSON(http.StatusOK, gin.H{"quips": quips})
}
// SetPostThumbnail PATCH /admin/posts/:id/thumbnail
// Body: {"thumbnail_url": "..."}
func (h *AdminHandler) SetPostThumbnail(c *gin.Context) {
postID := c.Param("id")
var req struct {
ThumbnailURL string `json:"thumbnail_url" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
_, err := h.pool.Exec(c.Request.Context(),
`UPDATE posts SET thumbnail_url = $1 WHERE id = $2`, req.ThumbnailURL, postID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "thumbnail updated"})
}
// RepairQuip POST /admin/quips/:id/repair
// Triggers FFmpeg frame extraction on the server and sets thumbnail_url.
func (h *AdminHandler) RepairQuip(c *gin.Context) {
postID := c.Param("id")
// Fetch video_url
var videoURL string
err := h.pool.QueryRow(c.Request.Context(),
`SELECT video_url FROM posts WHERE id = $1`, postID).Scan(&videoURL)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "post not found"})
return
}
if videoURL == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "post has no video_url"})
return
}
vp := services.NewVideoProcessor(h.s3Client, h.videoBucket, h.vidDomain)
frames, err := vp.ExtractFrames(c.Request.Context(), videoURL, 3)
if err != nil || len(frames) == 0 {
c.JSON(http.StatusInternalServerError, gin.H{"error": "frame extraction failed: " + func() string {
if err != nil {
return err.Error()
}
return "no frames"
}()})
return
}
thumbnail := frames[0]
_, err = h.pool.Exec(c.Request.Context(),
`UPDATE posts SET thumbnail_url = $1 WHERE id = $2`, thumbnail, postID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"thumbnail_url": thumbnail})
}
// ──────────────────────────────────────────────────────────────────────────────
// ──────────────────────────────────────────────
// Waitlist Management
// ──────────────────────────────────────────────
// AdminListWaitlist GET /admin/waitlist?status=&limit=&offset=
func (h *AdminHandler) AdminListWaitlist(c *gin.Context) {
ctx := c.Request.Context()
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
status := c.DefaultQuery("status", "")
if limit <= 0 || limit > 200 {
limit = 50
}
query := `SELECT id, email, name, referral_code, invited_by, status, notes, created_at, updated_at
FROM waitlist`
args := []interface{}{}
if status != "" {
query += " WHERE status = $1"
args = append(args, status)
}
query += fmt.Sprintf(" ORDER BY created_at DESC LIMIT $%d OFFSET $%d", len(args)+1, len(args)+2)
args = append(args, limit, offset)
rows, err := h.pool.Query(ctx, query, args...)
if err != nil {
// Table may not exist yet
c.JSON(http.StatusOK, gin.H{"entries": []gin.H{}, "total": 0})
return
}
defer rows.Close()
var entries []gin.H
for rows.Next() {
var id any // int or uuid depending on schema
var email string
var name, referralCode, invitedBy, wlStatus, notes *string
var createdAt, updatedAt time.Time
if err := rows.Scan(&id, &email, &name, &referralCode, &invitedBy, &wlStatus, &notes, &createdAt, &updatedAt); err == nil {
entries = append(entries, gin.H{
"id": fmt.Sprintf("%v", id), "email": email, "name": name,
"referral_code": referralCode, "invited_by": invitedBy,
"status": wlStatus, "notes": notes,
"created_at": createdAt, "updated_at": updatedAt,
})
}
}
if entries == nil {
entries = []gin.H{}
}
var total int
countQuery := "SELECT COUNT(*) FROM waitlist"
if status != "" {
_ = h.pool.QueryRow(ctx, countQuery+" WHERE status = $1", status).Scan(&total)
} else {
_ = h.pool.QueryRow(ctx, countQuery).Scan(&total)
}
c.JSON(http.StatusOK, gin.H{"entries": entries, "total": total, "limit": limit, "offset": offset})
}
// AdminUpdateWaitlist PATCH /admin/waitlist/:id
func (h *AdminHandler) AdminUpdateWaitlist(c *gin.Context) {
ctx := c.Request.Context()
id := c.Param("id")
var req struct {
Status string `json:"status"`
Notes string `json:"notes"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
_, err := h.pool.Exec(ctx,
`UPDATE waitlist SET status = COALESCE(NULLIF($1,''), status), notes = COALESCE(NULLIF($2,''), notes), updated_at = NOW() WHERE id = $3`,
req.Status, req.Notes, id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update waitlist entry"})
return
}
adminID, _ := c.Get("user_id")
h.pool.Exec(ctx, `INSERT INTO audit_log (actor_id, action, target_type, target_id, details) VALUES ($1, 'waitlist_update', 'waitlist', $2, $3)`,
adminID, id, fmt.Sprintf("status=%s", req.Status))
c.JSON(http.StatusOK, gin.H{"message": "Updated"})
}
// AdminDeleteWaitlist DELETE /admin/waitlist/:id
func (h *AdminHandler) AdminDeleteWaitlist(c *gin.Context) {
ctx := c.Request.Context()
id := c.Param("id")
_, err := h.pool.Exec(ctx, `DELETE FROM waitlist WHERE id = $1`, id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete waitlist entry"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Deleted"})
}
// ──────────────────────────────────────────────
// Feed Impression Reset
// ──────────────────────────────────────────────
// AdminResetFeedImpressions DELETE /admin/users/:id/feed-impressions
func (h *AdminHandler) AdminResetFeedImpressions(c *gin.Context) {
ctx := c.Request.Context()
userID := c.Param("id")
result, err := h.pool.Exec(ctx, `DELETE FROM user_feed_impressions WHERE user_id = $1`, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to reset feed impressions"})
return
}
adminID, _ := c.Get("user_id")
h.pool.Exec(ctx, `INSERT INTO audit_log (actor_id, action, target_type, target_id, details) VALUES ($1, 'reset_feed_impressions', 'user', $2, $3)`,
adminID, userID, "Admin reset feed impression history")
c.JSON(http.StatusOK, gin.H{"message": "Feed impressions reset", "deleted": result.RowsAffected()})
}
// Feed scores viewer
// ──────────────────────────────────────────────────────────────────────────────
// AdminGetFeedScores GET /admin/feed-scores?limit=50
func (h *AdminHandler) AdminGetFeedScores(c *gin.Context) {
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
if limit <= 0 || limit > 200 {
limit = 50
}
rows, err := h.pool.Query(c.Request.Context(), `
SELECT pfs.post_id,
LEFT(p.content, 80) AS excerpt,
pfs.engagement_score,
pfs.quality_score,
pfs.recency_score,
pfs.network_score,
pfs.personalization,
pfs.score AS total_score,
pfs.updated_at
FROM post_feed_scores pfs
JOIN posts p ON p.id = pfs.post_id
WHERE p.status = 'active'
ORDER BY pfs.score DESC
LIMIT $1
`, limit)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
defer rows.Close()
type scoreRow struct {
PostID string `json:"post_id"`
Excerpt string `json:"excerpt"`
EngagementScore float64 `json:"engagement_score"`
QualityScore float64 `json:"quality_score"`
RecencyScore float64 `json:"recency_score"`
NetworkScore float64 `json:"network_score"`
Personalization float64 `json:"personalization"`
TotalScore float64 `json:"total_score"`
UpdatedAt time.Time `json:"updated_at"`
}
var scores []scoreRow
for rows.Next() {
var s scoreRow
if err := rows.Scan(&s.PostID, &s.Excerpt, &s.EngagementScore, &s.QualityScore,
&s.RecencyScore, &s.NetworkScore, &s.Personalization, &s.TotalScore, &s.UpdatedAt); err != nil {
continue
}
scores = append(scores, s)
}
c.JSON(http.StatusOK, gin.H{"scores": scores})
}

View file

@ -0,0 +1,32 @@
package handlers
import (
"net/http"
"github.com/gin-gonic/gin"
"gitlab.com/patrickbritton3/sojorn/go-backend/internal/services"
)
func (h *AdminHandler) GetAltchaChallenge(c *gin.Context) {
altchaService := services.NewAltchaService(h.jwtSecret) // Use JWT secret as ALTCHA secret for now
challenge, err := altchaService.GenerateChallenge()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate challenge"})
return
}
c.JSON(http.StatusOK, challenge)
}
func (h *AuthHandler) GetAltchaChallenge(c *gin.Context) {
altchaService := services.NewAltchaService(h.config.JWTSecret)
challenge, err := altchaService.GenerateChallenge()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate challenge"})
return
}
c.JSON(http.StatusOK, challenge)
}

View file

@ -1,78 +0,0 @@
package handlers
import (
"fmt"
"io"
"net/http"
"net/url"
"github.com/gin-gonic/gin"
)
// AudioHandler proxies Funkwhale audio library requests so the Flutter app
// doesn't need CORS credentials or direct Funkwhale access.
type AudioHandler struct {
funkwhaleBase string // e.g. "http://localhost:5001" — empty = not yet deployed
}
func NewAudioHandler(funkwhaleBase string) *AudioHandler {
return &AudioHandler{funkwhaleBase: funkwhaleBase}
}
// SearchAudioLibrary proxies GET /audio/library?q=&page= to Funkwhale /api/v1/tracks/
func (h *AudioHandler) SearchAudioLibrary(c *gin.Context) {
if h.funkwhaleBase == "" {
c.JSON(http.StatusServiceUnavailable, gin.H{
"error": "audio library not yet configured — Funkwhale deployment pending",
"tracks": []any{},
"count": 0,
})
return
}
q := url.QueryEscape(c.DefaultQuery("q", ""))
page := url.QueryEscape(c.DefaultQuery("page", "1"))
target := fmt.Sprintf("%s/api/v1/tracks/?q=%s&page=%s&playable=true", h.funkwhaleBase, q, page)
resp, err := http.Get(target) //nolint:gosec
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "audio library unavailable"})
return
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
c.Data(resp.StatusCode, "application/json", body)
}
// GetAudioTrackListen proxies the audio stream for a track.
// Flutter uses this URL in ffmpeg_kit as the audio input.
func (h *AudioHandler) GetAudioTrackListen(c *gin.Context) {
if h.funkwhaleBase == "" {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "audio library not yet configured"})
return
}
trackID := c.Param("trackId")
target := fmt.Sprintf("%s/api/v1/listen/%s/", h.funkwhaleBase, url.PathEscape(trackID))
req, err := http.NewRequestWithContext(c.Request.Context(), http.MethodGet, target, nil)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create request"})
return
}
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "audio stream unavailable"})
return
}
defer resp.Body.Close()
contentType := resp.Header.Get("Content-Type")
if contentType == "" {
contentType = "audio/mpeg"
}
c.DataFromReader(resp.StatusCode, resp.ContentLength, contentType, resp.Body, nil)
}

View file

@ -204,148 +204,6 @@ func (h *CapsuleHandler) ListPublicClusters(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"clusters": clusters})
}
// ── Discover Groups (browse all public, non-encrypted groups) ────────────
// DiscoverGroups returns public groups the user can join, optionally filtered by category
func (h *CapsuleHandler) DiscoverGroups(c *gin.Context) {
userIDStr, _ := c.Get("user_id")
userID, err := uuid.Parse(userIDStr.(string))
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
category := c.Query("category") // optional filter
limitStr := c.DefaultQuery("limit", "50")
limit, _ := strconv.Atoi(limitStr)
if limit <= 0 || limit > 100 {
limit = 50
}
query := `
SELECT g.id, g.name, g.description, g.type, g.privacy,
COALESCE(g.avatar_url, '') AS avatar_url,
g.member_count, g.is_encrypted,
COALESCE(g.settings::text, '{}') AS settings,
g.key_version, COALESCE(g.category, 'general') AS category, g.created_at,
EXISTS(SELECT 1 FROM group_members gm WHERE gm.group_id = g.id AND gm.user_id = $1) AS is_member
FROM groups g
WHERE g.is_active = TRUE
AND g.is_encrypted = FALSE
AND g.privacy = 'public'
`
args := []interface{}{userID}
argIdx := 2
if category != "" && category != "all" {
query += fmt.Sprintf(" AND g.category = $%d", argIdx)
args = append(args, category)
argIdx++
}
query += " ORDER BY g.member_count DESC LIMIT " + strconv.Itoa(limit)
rows, err := h.pool.Query(c.Request.Context(), query, args...)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch groups"})
return
}
defer rows.Close()
var groups []gin.H
for rows.Next() {
var id uuid.UUID
var name, desc, typ, privacy, avatarURL, settings, cat string
var memberCount, keyVersion int
var isEncrypted, isMember bool
var createdAt time.Time
if err := rows.Scan(&id, &name, &desc, &typ, &privacy, &avatarURL,
&memberCount, &isEncrypted, &settings, &keyVersion, &cat, &createdAt, &isMember); err != nil {
continue
}
groups = append(groups, gin.H{
"id": id, "name": name, "description": desc, "type": typ,
"privacy": privacy, "avatar_url": avatarURL,
"member_count": memberCount, "is_encrypted": isEncrypted,
"settings": settings, "key_version": keyVersion,
"category": cat, "created_at": createdAt, "is_member": isMember,
})
}
if groups == nil {
groups = []gin.H{}
}
c.JSON(http.StatusOK, gin.H{"groups": groups})
}
// JoinGroup adds the authenticated user to a public, non-encrypted group
func (h *CapsuleHandler) JoinGroup(c *gin.Context) {
userIDStr, _ := c.Get("user_id")
userID, err := uuid.Parse(userIDStr.(string))
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
groupID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid group id"})
return
}
ctx := c.Request.Context()
// Verify group exists, is public, and not encrypted
var privacy string
var isEncrypted bool
err = h.pool.QueryRow(ctx, `SELECT privacy, is_encrypted FROM groups WHERE id = $1 AND is_active = TRUE`, groupID).Scan(&privacy, &isEncrypted)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "group not found"})
return
}
if isEncrypted {
c.JSON(http.StatusForbidden, gin.H{"error": "cannot join encrypted groups directly"})
return
}
if privacy != "public" {
c.JSON(http.StatusForbidden, gin.H{"error": "this group requires an invitation"})
return
}
// Check if already a member
var exists bool
h.pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM group_members WHERE group_id = $1 AND user_id = $2)`, groupID, userID).Scan(&exists)
if exists {
c.JSON(http.StatusConflict, gin.H{"error": "already a member"})
return
}
// Add member and increment count
tx, err := h.pool.Begin(ctx)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "transaction failed"})
return
}
defer tx.Rollback(ctx)
_, err = tx.Exec(ctx, `INSERT INTO group_members (group_id, user_id, role) VALUES ($1, $2, 'member')`, groupID, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to join group"})
return
}
_, err = tx.Exec(ctx, `UPDATE groups SET member_count = member_count + 1 WHERE id = $1`, groupID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update count"})
return
}
if err := tx.Commit(ctx); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "commit failed"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "joined group"})
}
// ── Private Capsule Endpoints ────────────────────────────────────────────
// CRITICAL: The server NEVER decrypts payload. It only checks membership
// and returns encrypted blobs.
@ -761,64 +619,3 @@ func (h *CapsuleHandler) RotateKeys(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "keys_rotated"})
}
// ReportCapsuleEntry stores a member's report of an encrypted entry.
// The client voluntarily decrypts the payload to provide plaintext evidence.
func (h *CapsuleHandler) ReportCapsuleEntry(c *gin.Context) {
userIDStr, _ := c.Get("user_id")
userID, _ := uuid.Parse(userIDStr.(string))
groupID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid group ID"})
return
}
entryID, err := uuid.Parse(c.Param("entryId"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid entry ID"})
return
}
ctx := c.Request.Context()
// Verify membership
var isMember bool
h.pool.QueryRow(ctx,
`SELECT EXISTS(SELECT 1 FROM group_members WHERE group_id = $1 AND user_id = $2)`,
groupID, userID,
).Scan(&isMember)
if !isMember {
c.JSON(http.StatusForbidden, gin.H{"error": "not a member"})
return
}
var req struct {
Reason string `json:"reason" binding:"required"`
DecryptedSample *string `json:"decrypted_sample"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "reason required"})
return
}
// Prevent duplicate reports from the same user for the same entry
var alreadyReported bool
h.pool.QueryRow(ctx,
`SELECT EXISTS(SELECT 1 FROM capsule_reports WHERE reporter_id = $1 AND entry_id = $2)`,
userID, entryID,
).Scan(&alreadyReported)
if alreadyReported {
c.JSON(http.StatusConflict, gin.H{"error": "already reported"})
return
}
_, err = h.pool.Exec(ctx, `
INSERT INTO capsule_reports (reporter_id, capsule_id, entry_id, decrypted_sample, reason)
VALUES ($1, $2, $3, $4, $5)
`, userID, groupID, entryID, req.DecryptedSample, req.Reason)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to store report"})
return
}
c.JSON(http.StatusCreated, gin.H{"message": "Report submitted"})
}

View file

@ -1,255 +0,0 @@
package handlers
import (
"context"
"net/http"
"github.com/gin-gonic/gin"
"github.com/jackc/pgx/v5/pgxpool"
)
type FollowHandler struct {
db *pgxpool.Pool
}
func NewFollowHandler(db *pgxpool.Pool) *FollowHandler {
return &FollowHandler{db: db}
}
// FollowUser — POST /users/:userId/follow
func (h *FollowHandler) FollowUser(c *gin.Context) {
userID := c.GetString("user_id")
targetUserID := c.Param("id")
if userID == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
if userID == targetUserID {
c.JSON(http.StatusBadRequest, gin.H{"error": "cannot follow yourself"})
return
}
_, err := h.db.Exec(context.Background(), `
INSERT INTO follows (follower_id, following_id)
VALUES ($1, $2)
ON CONFLICT (follower_id, following_id) DO NOTHING
`, userID, targetUserID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to follow user"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "followed"})
}
// UnfollowUser — POST /users/:userId/unfollow
func (h *FollowHandler) UnfollowUser(c *gin.Context) {
userID := c.GetString("user_id")
targetUserID := c.Param("id")
if userID == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
_, err := h.db.Exec(context.Background(), `
DELETE FROM follows WHERE follower_id = $1 AND following_id = $2
`, userID, targetUserID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to unfollow user"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "unfollowed"})
}
// IsFollowing — GET /users/:userId/is-following
func (h *FollowHandler) IsFollowing(c *gin.Context) {
userID := c.GetString("user_id")
targetUserID := c.Param("id")
if userID == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
var isFollowing bool
err := h.db.QueryRow(context.Background(), `
SELECT EXISTS(
SELECT 1 FROM follows WHERE follower_id = $1 AND following_id = $2
)
`, userID, targetUserID).Scan(&isFollowing)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to check follow status"})
return
}
c.JSON(http.StatusOK, gin.H{"is_following": isFollowing})
}
// GetMutualFollowers — GET /users/:userId/mutual-followers
func (h *FollowHandler) GetMutualFollowers(c *gin.Context) {
userID := c.GetString("user_id")
targetUserID := c.Param("id")
if userID == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
rows, err := h.db.Query(context.Background(), `
SELECT p.id, p.handle, p.display_name, p.avatar_url
FROM profiles p
WHERE p.id IN (
SELECT following_id FROM follows WHERE follower_id = $1
)
AND p.id IN (
SELECT following_id FROM follows WHERE follower_id = $2
)
LIMIT 50
`, userID, targetUserID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get mutual followers"})
return
}
defer rows.Close()
type mutualUser struct {
ID string `json:"id"`
Handle string `json:"handle"`
DisplayName string `json:"display_name"`
AvatarURL *string `json:"avatar_url"`
}
users := []mutualUser{}
for rows.Next() {
var u mutualUser
if err := rows.Scan(&u.ID, &u.Handle, &u.DisplayName, &u.AvatarURL); err == nil {
users = append(users, u)
}
}
c.JSON(http.StatusOK, gin.H{"mutual_followers": users})
}
// GetSuggestedUsers — GET /users/suggested
func (h *FollowHandler) GetSuggestedUsers(c *gin.Context) {
userID := c.GetString("user_id")
if userID == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
// Suggest users followed by people the current user follows, excluding already-followed
rows, err := h.db.Query(context.Background(), `
SELECT DISTINCT p.id, p.handle, p.display_name, p.avatar_url,
COUNT(f2.follower_id) AS mutual_count
FROM follows f1
JOIN follows f2 ON f2.follower_id = f1.following_id
JOIN profiles p ON p.id = f2.following_id
WHERE f1.follower_id = $1
AND f2.following_id != $1
AND f2.following_id NOT IN (
SELECT following_id FROM follows WHERE follower_id = $1
)
GROUP BY p.id, p.handle, p.display_name, p.avatar_url
ORDER BY mutual_count DESC
LIMIT 10
`, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get suggestions"})
return
}
defer rows.Close()
type suggestedUser struct {
ID string `json:"id"`
Handle string `json:"handle"`
DisplayName string `json:"display_name"`
AvatarURL *string `json:"avatar_url"`
MutualCount int `json:"mutual_count"`
}
suggestions := []suggestedUser{}
for rows.Next() {
var u suggestedUser
if err := rows.Scan(&u.ID, &u.Handle, &u.DisplayName, &u.AvatarURL, &u.MutualCount); err == nil {
suggestions = append(suggestions, u)
}
}
c.JSON(http.StatusOK, gin.H{"suggestions": suggestions})
}
// GetFollowers — GET /users/:userId/followers
func (h *FollowHandler) GetFollowers(c *gin.Context) {
targetUserID := c.Param("id")
rows, err := h.db.Query(context.Background(), `
SELECT p.id, p.handle, p.display_name, p.avatar_url, f.created_at
FROM follows f
JOIN profiles p ON f.follower_id = p.id
WHERE f.following_id = $1
ORDER BY f.created_at DESC
LIMIT 100
`, targetUserID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get followers"})
return
}
defer rows.Close()
type follower struct {
ID string `json:"id"`
Handle string `json:"handle"`
DisplayName string `json:"display_name"`
AvatarURL *string `json:"avatar_url"`
FollowedAt string `json:"followed_at"`
}
followers := []follower{}
for rows.Next() {
var f follower
var followedAt interface{}
if err := rows.Scan(&f.ID, &f.Handle, &f.DisplayName, &f.AvatarURL, &followedAt); err == nil {
followers = append(followers, f)
}
}
c.JSON(http.StatusOK, gin.H{"followers": followers})
}
// GetFollowing — GET /users/:userId/following
func (h *FollowHandler) GetFollowing(c *gin.Context) {
targetUserID := c.Param("id")
rows, err := h.db.Query(context.Background(), `
SELECT p.id, p.handle, p.display_name, p.avatar_url, f.created_at
FROM follows f
JOIN profiles p ON f.following_id = p.id
WHERE f.follower_id = $1
ORDER BY f.created_at DESC
LIMIT 100
`, targetUserID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get following"})
return
}
defer rows.Close()
type followingUser struct {
ID string `json:"id"`
Handle string `json:"handle"`
DisplayName string `json:"display_name"`
AvatarURL *string `json:"avatar_url"`
FollowedAt string `json:"followed_at"`
}
following := []followingUser{}
for rows.Next() {
var f followingUser
var followedAt interface{}
if err := rows.Scan(&f.ID, &f.Handle, &f.DisplayName, &f.AvatarURL, &followedAt); err == nil {
following = append(following, f)
}
}
c.JSON(http.StatusOK, gin.H{"following": following})
}

View file

@ -1,915 +0,0 @@
package handlers
import (
"database/sql"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/jackc/pgx/v5/pgxpool"
)
type GroupsHandler struct {
db *pgxpool.Pool
}
func NewGroupsHandler(db *pgxpool.Pool) *GroupsHandler {
return &GroupsHandler{db: db}
}
type Group struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Category string `json:"category"`
AvatarURL *string `json:"avatar_url"`
BannerURL *string `json:"banner_url"`
IsPrivate bool `json:"is_private"`
CreatedBy string `json:"created_by"`
MemberCount int `json:"member_count"`
PostCount int `json:"post_count"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
UserRole *string `json:"user_role,omitempty"`
IsMember bool `json:"is_member"`
HasPending bool `json:"has_pending_request,omitempty"`
}
type GroupMember struct {
ID string `json:"id"`
GroupID string `json:"group_id"`
UserID string `json:"user_id"`
Role string `json:"role"`
JoinedAt time.Time `json:"joined_at"`
Username string `json:"username,omitempty"`
Avatar *string `json:"avatar_url,omitempty"`
}
type JoinRequest struct {
ID string `json:"id"`
GroupID string `json:"group_id"`
UserID string `json:"user_id"`
Status string `json:"status"`
Message *string `json:"message"`
CreatedAt time.Time `json:"created_at"`
ReviewedAt *time.Time `json:"reviewed_at"`
ReviewedBy *string `json:"reviewed_by"`
Username string `json:"username,omitempty"`
Avatar *string `json:"avatar_url,omitempty"`
}
// ListGroups returns all groups with optional category filter
func (h *GroupsHandler) ListGroups(c *gin.Context) {
userID := c.GetString("user_id")
category := c.Query("category")
page := c.DefaultQuery("page", "0")
limit := c.DefaultQuery("limit", "20")
query := `
SELECT g.id, g.name, g.description, g.category, g.avatar_url, g.banner_url,
g.is_private, g.created_by, g.member_count, g.post_count, g.created_at, g.updated_at,
gm.role,
EXISTS(SELECT 1 FROM group_members WHERE group_id = g.id AND user_id = $1) as is_member,
EXISTS(SELECT 1 FROM group_join_requests WHERE group_id = g.id AND user_id = $1 AND status = 'pending') as has_pending
FROM groups g
LEFT JOIN group_members gm ON g.id = gm.group_id AND gm.user_id = $1
WHERE ($2 = '' OR g.category = $2)
ORDER BY g.member_count DESC, g.created_at DESC
LIMIT $3 OFFSET $4
`
rows, err := h.db.Query(c.Request.Context(), query, userID, category, limit, page)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch groups"})
return
}
defer rows.Close()
groups := []Group{}
for rows.Next() {
var g Group
err := rows.Scan(&g.ID, &g.Name, &g.Description, &g.Category, &g.AvatarURL, &g.BannerURL,
&g.IsPrivate, &g.CreatedBy, &g.MemberCount, &g.PostCount, &g.CreatedAt, &g.UpdatedAt,
&g.UserRole, &g.IsMember, &g.HasPending)
if err != nil {
continue
}
groups = append(groups, g)
}
c.JSON(http.StatusOK, gin.H{"groups": groups})
}
// GetMyGroups returns groups the user is a member of
func (h *GroupsHandler) GetMyGroups(c *gin.Context) {
userID := c.GetString("user_id")
query := `
SELECT g.id, g.name, g.description, g.category, g.avatar_url, g.banner_url,
g.is_private, g.created_by, g.member_count, g.post_count, g.created_at, g.updated_at,
gm.role
FROM groups g
JOIN group_members gm ON g.id = gm.group_id
WHERE gm.user_id = $1
ORDER BY gm.joined_at DESC
`
rows, err := h.db.Query(c.Request.Context(), query, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch groups"})
return
}
defer rows.Close()
groups := []Group{}
for rows.Next() {
var g Group
g.IsMember = true
err := rows.Scan(&g.ID, &g.Name, &g.Description, &g.Category, &g.AvatarURL, &g.BannerURL,
&g.IsPrivate, &g.CreatedBy, &g.MemberCount, &g.PostCount, &g.CreatedAt, &g.UpdatedAt,
&g.UserRole)
if err != nil {
continue
}
groups = append(groups, g)
}
c.JSON(http.StatusOK, gin.H{"groups": groups})
}
// GetSuggestedGroups returns suggested groups for the user
func (h *GroupsHandler) GetSuggestedGroups(c *gin.Context) {
userID := c.GetString("user_id")
limit := c.DefaultQuery("limit", "10")
query := `SELECT * FROM get_suggested_groups($1, $2)`
rows, err := h.db.Query(c.Request.Context(), query, userID, limit)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch suggestions"})
return
}
defer rows.Close()
type SuggestedGroup struct {
Group
Reason string `json:"reason"`
}
groups := []SuggestedGroup{}
for rows.Next() {
var sg SuggestedGroup
err := rows.Scan(&sg.ID, &sg.Name, &sg.Description, &sg.Category, &sg.AvatarURL,
&sg.IsPrivate, &sg.MemberCount, &sg.PostCount, &sg.Reason)
if err != nil {
continue
}
sg.IsMember = false
groups = append(groups, sg)
}
c.JSON(http.StatusOK, gin.H{"suggestions": groups})
}
// GetGroup returns a single group by ID
func (h *GroupsHandler) GetGroup(c *gin.Context) {
userID := c.GetString("user_id")
groupID := c.Param("id")
query := `
SELECT g.id, g.name, g.description, g.category, g.avatar_url, g.banner_url,
g.is_private, g.created_by, g.member_count, g.post_count, g.created_at, g.updated_at,
gm.role,
EXISTS(SELECT 1 FROM group_members WHERE group_id = g.id AND user_id = $2) as is_member,
EXISTS(SELECT 1 FROM group_join_requests WHERE group_id = g.id AND user_id = $2 AND status = 'pending') as has_pending
FROM groups g
LEFT JOIN group_members gm ON g.id = gm.group_id AND gm.user_id = $2
WHERE g.id = $1
`
var g Group
err := h.db.QueryRow(c.Request.Context(), query, groupID, userID).Scan(
&g.ID, &g.Name, &g.Description, &g.Category, &g.AvatarURL, &g.BannerURL,
&g.IsPrivate, &g.CreatedBy, &g.MemberCount, &g.PostCount, &g.CreatedAt, &g.UpdatedAt,
&g.UserRole, &g.IsMember, &g.HasPending)
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "Group not found"})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch group"})
return
}
c.JSON(http.StatusOK, gin.H{"group": g})
}
// CreateGroup creates a new group
func (h *GroupsHandler) CreateGroup(c *gin.Context) {
userID := c.GetString("user_id")
var req struct {
Name string `json:"name" binding:"required,max=50"`
Description string `json:"description" binding:"max=300"`
Category string `json:"category" binding:"required"`
IsPrivate bool `json:"is_private"`
AvatarURL *string `json:"avatar_url"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Normalize name for uniqueness check
req.Name = strings.TrimSpace(req.Name)
privacy := "public"
if req.IsPrivate {
privacy = "private"
}
tx, err := h.db.Begin(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create group"})
return
}
defer tx.Rollback(c.Request.Context())
// Create group
var groupID string
err = tx.QueryRow(c.Request.Context(), `
INSERT INTO groups (name, description, category, privacy, created_by, avatar_url)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id
`, req.Name, req.Description, req.Category, privacy, userID, req.AvatarURL).Scan(&groupID)
if err != nil {
if strings.Contains(err.Error(), "duplicate") || strings.Contains(err.Error(), "unique") {
c.JSON(http.StatusConflict, gin.H{"error": "A group with this name already exists"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create group"})
return
}
// Add creator as owner
_, err = tx.Exec(c.Request.Context(), `
INSERT INTO group_members (group_id, user_id, role)
VALUES ($1, $2, 'owner')
`, groupID, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to add owner"})
return
}
if err = tx.Commit(c.Request.Context()); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create group"})
return
}
c.JSON(http.StatusCreated, gin.H{"group_id": groupID, "message": "Group created successfully"})
}
// JoinGroup allows a user to join a public group or request to join a private group
func (h *GroupsHandler) JoinGroup(c *gin.Context) {
userID := c.GetString("user_id")
groupID := c.Param("id")
var req struct {
Message *string `json:"message"`
}
c.ShouldBindJSON(&req)
// Check if group exists and is private
var isPrivate bool
err := h.db.QueryRow(c.Request.Context(), `SELECT is_private FROM groups WHERE id = $1`, groupID).Scan(&isPrivate)
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "Group not found"})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to join group"})
return
}
// Check if already a member
var exists bool
err = h.db.QueryRow(c.Request.Context(), `
SELECT EXISTS(SELECT 1 FROM group_members WHERE group_id = $1 AND user_id = $2)
`, groupID, userID).Scan(&exists)
if err == nil && exists {
c.JSON(http.StatusConflict, gin.H{"error": "Already a member"})
return
}
if isPrivate {
// Create join request
_, err = h.db.Exec(c.Request.Context(), `
INSERT INTO group_join_requests (group_id, user_id, message)
VALUES ($1, $2, $3)
ON CONFLICT (group_id, user_id, status) DO NOTHING
`, groupID, userID, req.Message)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create join request"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Join request sent", "status": "pending"})
} else {
// Join immediately
_, err = h.db.Exec(c.Request.Context(), `
INSERT INTO group_members (group_id, user_id, role)
VALUES ($1, $2, 'member')
`, groupID, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to join group"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Joined successfully", "status": "joined"})
}
}
// LeaveGroup allows a user to leave a group
func (h *GroupsHandler) LeaveGroup(c *gin.Context) {
userID := c.GetString("user_id")
groupID := c.Param("id")
// Check if user is owner
var role string
err := h.db.QueryRow(c.Request.Context(), `
SELECT role FROM group_members WHERE group_id = $1 AND user_id = $2
`, groupID, userID).Scan(&role)
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "Not a member of this group"})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to leave group"})
return
}
if role == "owner" {
c.JSON(http.StatusForbidden, gin.H{"error": "Owner must transfer ownership or delete group before leaving"})
return
}
// Remove member
_, err = h.db.Exec(c.Request.Context(), `
DELETE FROM group_members WHERE group_id = $1 AND user_id = $2
`, groupID, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to leave group"})
return
}
// Flag key rotation so admin client silently rotates on next open
h.db.Exec(c.Request.Context(),
`UPDATE groups SET key_rotation_needed = true WHERE id = $1`, groupID)
c.JSON(http.StatusOK, gin.H{"message": "Left group successfully"})
}
// GetGroupMembers returns members of a group
func (h *GroupsHandler) GetGroupMembers(c *gin.Context) {
groupID := c.Param("id")
page := c.DefaultQuery("page", "0")
limit := c.DefaultQuery("limit", "50")
query := `
SELECT gm.id, gm.group_id, gm.user_id, gm.role, gm.joined_at,
p.username, p.avatar_url
FROM group_members gm
JOIN profiles p ON gm.user_id = p.user_id
WHERE gm.group_id = $1
ORDER BY
CASE gm.role
WHEN 'owner' THEN 1
WHEN 'admin' THEN 2
WHEN 'moderator' THEN 3
ELSE 4
END,
gm.joined_at ASC
LIMIT $2 OFFSET $3
`
rows, err := h.db.Query(c.Request.Context(), query, groupID, limit, page)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch members"})
return
}
defer rows.Close()
members := []GroupMember{}
for rows.Next() {
var m GroupMember
err := rows.Scan(&m.ID, &m.GroupID, &m.UserID, &m.Role, &m.JoinedAt, &m.Username, &m.Avatar)
if err != nil {
continue
}
members = append(members, m)
}
c.JSON(http.StatusOK, gin.H{"members": members})
}
// GetPendingRequests returns pending join requests for a group (admin only)
func (h *GroupsHandler) GetPendingRequests(c *gin.Context) {
userID := c.GetString("user_id")
groupID := c.Param("id")
// Check if user is admin/owner
var role string
err := h.db.QueryRow(c.Request.Context(), `
SELECT role FROM group_members WHERE group_id = $1 AND user_id = $2
`, groupID, userID).Scan(&role)
if err != nil || (role != "owner" && role != "admin") {
c.JSON(http.StatusForbidden, gin.H{"error": "Insufficient permissions"})
return
}
query := `
SELECT jr.id, jr.group_id, jr.user_id, jr.status, jr.message, jr.created_at,
jr.reviewed_at, jr.reviewed_by, p.username, p.avatar_url
FROM group_join_requests jr
JOIN profiles p ON jr.user_id = p.user_id
WHERE jr.group_id = $1 AND jr.status = 'pending'
ORDER BY jr.created_at ASC
`
rows, err := h.db.Query(c.Request.Context(), query, groupID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch requests"})
return
}
defer rows.Close()
requests := []JoinRequest{}
for rows.Next() {
var jr JoinRequest
err := rows.Scan(&jr.ID, &jr.GroupID, &jr.UserID, &jr.Status, &jr.Message, &jr.CreatedAt,
&jr.ReviewedAt, &jr.ReviewedBy, &jr.Username, &jr.Avatar)
if err != nil {
continue
}
requests = append(requests, jr)
}
c.JSON(http.StatusOK, gin.H{"requests": requests})
}
// ApproveJoinRequest approves a join request (admin only)
func (h *GroupsHandler) ApproveJoinRequest(c *gin.Context) {
userID := c.GetString("user_id")
groupID := c.Param("id")
requestID := c.Param("requestId")
// Check if user is admin/owner
var role string
err := h.db.QueryRow(c.Request.Context(), `
SELECT role FROM group_members WHERE group_id = $1 AND user_id = $2
`, groupID, userID).Scan(&role)
if err != nil || (role != "owner" && role != "admin") {
c.JSON(http.StatusForbidden, gin.H{"error": "Insufficient permissions"})
return
}
tx, err := h.db.Begin(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to approve request"})
return
}
defer tx.Rollback(c.Request.Context())
// Get requester user ID
var requesterID string
err = tx.QueryRow(c.Request.Context(), `
SELECT user_id FROM group_join_requests WHERE id = $1 AND group_id = $2 AND status = 'pending'
`, requestID, groupID).Scan(&requesterID)
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "Request not found"})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to approve request"})
return
}
// Update request status
_, err = tx.Exec(c.Request.Context(), `
UPDATE group_join_requests
SET status = 'approved', reviewed_at = NOW(), reviewed_by = $1
WHERE id = $2
`, userID, requestID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to approve request"})
return
}
// Add user as member
_, err = tx.Exec(c.Request.Context(), `
INSERT INTO group_members (group_id, user_id, role)
VALUES ($1, $2, 'member')
`, groupID, requesterID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to add member"})
return
}
if err = tx.Commit(c.Request.Context()); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to approve request"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Request approved"})
}
// RejectJoinRequest rejects a join request (admin only)
func (h *GroupsHandler) RejectJoinRequest(c *gin.Context) {
userID := c.GetString("user_id")
groupID := c.Param("id")
requestID := c.Param("requestId")
// Check if user is admin/owner
var role string
err := h.db.QueryRow(c.Request.Context(), `
SELECT role FROM group_members WHERE group_id = $1 AND user_id = $2
`, groupID, userID).Scan(&role)
if err != nil || (role != "owner" && role != "admin") {
c.JSON(http.StatusForbidden, gin.H{"error": "Insufficient permissions"})
return
}
_, err = h.db.Exec(c.Request.Context(), `
UPDATE group_join_requests
SET status = 'rejected', reviewed_at = NOW(), reviewed_by = $1
WHERE id = $2 AND group_id = $3 AND status = 'pending'
`, userID, requestID, groupID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to reject request"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Request rejected"})
}
// ──────────────────────────────────────────────────────────────────────────────
// Group feed
// ──────────────────────────────────────────────────────────────────────────────
// GetGroupFeed GET /groups/:id/feed?limit=20&offset=0
func (h *GroupsHandler) GetGroupFeed(c *gin.Context) {
groupID := c.Param("id")
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
if limit <= 0 || limit > 100 {
limit = 20
}
rows, err := h.db.Query(c.Request.Context(), `
SELECT p.id, p.user_id, p.content, p.image_url, p.video_url,
p.thumbnail_url, p.created_at, p.status
FROM posts p
JOIN group_posts gp ON gp.post_id = p.id
WHERE gp.group_id = $1 AND p.status = 'active'
ORDER BY p.created_at DESC
LIMIT $2 OFFSET $3
`, groupID, limit, offset)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch group feed"})
return
}
defer rows.Close()
type feedPost struct {
ID string `json:"id"`
UserID string `json:"user_id"`
Content string `json:"content"`
ImageURL *string `json:"image_url"`
VideoURL *string `json:"video_url"`
ThumbnailURL *string `json:"thumbnail_url"`
CreatedAt time.Time `json:"created_at"`
Status string `json:"status"`
}
var posts []feedPost
for rows.Next() {
var p feedPost
if err := rows.Scan(&p.ID, &p.UserID, &p.Content, &p.ImageURL, &p.VideoURL,
&p.ThumbnailURL, &p.CreatedAt, &p.Status); err != nil {
continue
}
posts = append(posts, p)
}
c.JSON(http.StatusOK, gin.H{"posts": posts, "limit": limit, "offset": offset})
}
// ──────────────────────────────────────────────────────────────────────────────
// E2EE group key management
// ──────────────────────────────────────────────────────────────────────────────
// GetGroupKeyStatus GET /groups/:id/key-status
// Returns the current key version, whether rotation is needed, and the caller's
// encrypted group key (if they have one).
func (h *GroupsHandler) GetGroupKeyStatus(c *gin.Context) {
groupID := c.Param("id")
userID, _ := c.Get("user_id")
var keyVersion int
var keyRotationNeeded bool
err := h.db.QueryRow(c.Request.Context(),
`SELECT key_version, key_rotation_needed FROM groups WHERE id = $1`, groupID,
).Scan(&keyVersion, &keyRotationNeeded)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "group not found"})
return
}
// Fetch this user's encrypted key for the current version
var encryptedKey *string
h.db.QueryRow(c.Request.Context(),
`SELECT encrypted_key FROM group_member_keys
WHERE group_id = $1 AND user_id = $2 AND key_version = $3`,
groupID, userID, keyVersion,
).Scan(&encryptedKey)
c.JSON(http.StatusOK, gin.H{
"key_version": keyVersion,
"key_rotation_needed": keyRotationNeeded,
"my_encrypted_key": encryptedKey,
})
}
// DistributeGroupKeys POST /groups/:id/keys
// Called by an admin/owner client after local key rotation to push new
// encrypted copies to each member.
// Body: {"keys": [{"user_id": "...", "encrypted_key": "...", "key_version": N}]}
func (h *GroupsHandler) DistributeGroupKeys(c *gin.Context) {
groupID := c.Param("id")
callerID, _ := c.Get("user_id")
// Only owner/admin may distribute keys
var role string
err := h.db.QueryRow(c.Request.Context(),
`SELECT role FROM group_members WHERE group_id = $1 AND user_id = $2`,
groupID, callerID,
).Scan(&role)
if err != nil || (role != "owner" && role != "admin") {
c.JSON(http.StatusForbidden, gin.H{"error": "only group owners or admins may rotate keys"})
return
}
var req struct {
Keys []struct {
UserID string `json:"user_id" binding:"required"`
EncryptedKey string `json:"encrypted_key" binding:"required"`
KeyVersion int `json:"key_version" binding:"required"`
} `json:"keys" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Determine the new key version (max of submitted versions)
newVersion := 0
for _, k := range req.Keys {
if k.KeyVersion > newVersion {
newVersion = k.KeyVersion
}
}
for _, k := range req.Keys {
h.db.Exec(c.Request.Context(), `
INSERT INTO group_member_keys (group_id, user_id, key_version, encrypted_key, updated_at)
VALUES ($1, $2, $3, $4, now())
ON CONFLICT (group_id, user_id, key_version)
DO UPDATE SET encrypted_key = EXCLUDED.encrypted_key, updated_at = now()
`, groupID, k.UserID, k.KeyVersion, k.EncryptedKey)
}
// Clear the rotation flag and bump key_version on the group
h.db.Exec(c.Request.Context(),
`UPDATE groups SET key_rotation_needed = false, key_version = $1 WHERE id = $2`,
newVersion, groupID)
c.JSON(http.StatusOK, gin.H{"message": "keys distributed", "key_version": newVersion})
}
// GetGroupMemberPublicKeys GET /groups/:id/members/public-keys
// Returns RSA public keys for all members so a rotating client can encrypt for each.
func (h *GroupsHandler) GetGroupMemberPublicKeys(c *gin.Context) {
groupID := c.Param("id")
callerID, _ := c.Get("user_id")
// Caller must be a member
var memberCount int
err := h.db.QueryRow(c.Request.Context(),
`SELECT COUNT(*) FROM group_members WHERE group_id = $1 AND user_id = $2`,
groupID, callerID,
).Scan(&memberCount)
if err != nil || memberCount == 0 {
c.JSON(http.StatusForbidden, gin.H{"error": "not a group member"})
return
}
rows, err := h.db.Query(c.Request.Context(), `
SELECT gm.user_id, u.public_key
FROM group_members gm
JOIN users u ON u.id = gm.user_id
WHERE gm.group_id = $1 AND u.public_key IS NOT NULL AND u.public_key != ''
`, groupID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch member keys"})
return
}
defer rows.Close()
type memberKey struct {
UserID string `json:"user_id"`
PublicKey string `json:"public_key"`
}
var keys []memberKey
for rows.Next() {
var mk memberKey
if rows.Scan(&mk.UserID, &mk.PublicKey) == nil {
keys = append(keys, mk)
}
}
c.JSON(http.StatusOK, gin.H{"keys": keys})
}
// ──────────────────────────────────────────────────────────────────────────────
// Member invite / remove / settings
// ──────────────────────────────────────────────────────────────────────────────
// InviteMember POST /groups/:id/invite-member
// Body: {"user_id": "...", "encrypted_key": "..."}
func (h *GroupsHandler) InviteMember(c *gin.Context) {
groupID := c.Param("id")
callerID, _ := c.Get("user_id")
var role string
err := h.db.QueryRow(c.Request.Context(),
`SELECT role FROM group_members WHERE group_id = $1 AND user_id = $2`,
groupID, callerID,
).Scan(&role)
if err != nil || (role != "owner" && role != "admin") {
c.JSON(http.StatusForbidden, gin.H{"error": "only group owners or admins may invite members"})
return
}
var req struct {
UserID string `json:"user_id" binding:"required"`
EncryptedKey string `json:"encrypted_key"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Fetch current key version
var keyVersion int
h.db.QueryRow(c.Request.Context(),
`SELECT key_version FROM groups WHERE id = $1`, groupID,
).Scan(&keyVersion)
// Add member
_, err = h.db.Exec(c.Request.Context(), `
INSERT INTO group_members (group_id, user_id, role, joined_at)
VALUES ($1, $2, 'member', now())
ON CONFLICT (group_id, user_id) DO NOTHING
`, groupID, req.UserID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to add member"})
return
}
// Store their encrypted key if provided
if req.EncryptedKey != "" {
h.db.Exec(c.Request.Context(), `
INSERT INTO group_member_keys (group_id, user_id, key_version, encrypted_key, updated_at)
VALUES ($1, $2, $3, $4, now())
ON CONFLICT (group_id, user_id, key_version)
DO UPDATE SET encrypted_key = EXCLUDED.encrypted_key, updated_at = now()
`, groupID, req.UserID, keyVersion, req.EncryptedKey)
}
c.JSON(http.StatusOK, gin.H{"message": "member invited"})
}
// RemoveMember DELETE /groups/:id/members/:userId
func (h *GroupsHandler) RemoveMember(c *gin.Context) {
groupID := c.Param("id")
targetUserID := c.Param("userId")
callerID, _ := c.Get("user_id")
var role string
err := h.db.QueryRow(c.Request.Context(),
`SELECT role FROM group_members WHERE group_id = $1 AND user_id = $2`,
groupID, callerID,
).Scan(&role)
if err != nil || (role != "owner" && role != "admin") {
c.JSON(http.StatusForbidden, gin.H{"error": "only group owners or admins may remove members"})
return
}
_, err = h.db.Exec(c.Request.Context(),
`DELETE FROM group_members WHERE group_id = $1 AND user_id = $2`,
groupID, targetUserID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to remove member"})
return
}
// Trigger automatic key rotation on next admin open
h.db.Exec(c.Request.Context(),
`UPDATE groups SET key_rotation_needed = true WHERE id = $1`, groupID)
c.JSON(http.StatusOK, gin.H{"message": "member removed"})
}
// UpdateGroupSettings PATCH /groups/:id/settings
// Body: {"chat_enabled": true, "forum_enabled": false, "vault_enabled": true}
func (h *GroupsHandler) UpdateGroupSettings(c *gin.Context) {
groupID := c.Param("id")
callerID, _ := c.Get("user_id")
var role string
err := h.db.QueryRow(c.Request.Context(),
`SELECT role FROM group_members WHERE group_id = $1 AND user_id = $2`,
groupID, callerID,
).Scan(&role)
if err != nil || (role != "owner" && role != "admin") {
c.JSON(http.StatusForbidden, gin.H{"error": "only group owners or admins may change settings"})
return
}
var req struct {
ChatEnabled *bool `json:"chat_enabled"`
ForumEnabled *bool `json:"forum_enabled"`
VaultEnabled *bool `json:"vault_enabled"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Build dynamic UPDATE (only fields provided)
setClauses := []string{}
args := []interface{}{}
argIdx := 1
if req.ChatEnabled != nil {
setClauses = append(setClauses, fmt.Sprintf("chat_enabled = $%d", argIdx))
args = append(args, *req.ChatEnabled)
argIdx++
}
if req.ForumEnabled != nil {
setClauses = append(setClauses, fmt.Sprintf("forum_enabled = $%d", argIdx))
args = append(args, *req.ForumEnabled)
argIdx++
}
if req.VaultEnabled != nil {
setClauses = append(setClauses, fmt.Sprintf("vault_enabled = $%d", argIdx))
args = append(args, *req.VaultEnabled)
argIdx++
}
if len(setClauses) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "no settings provided"})
return
}
query := fmt.Sprintf(
"UPDATE groups SET %s WHERE id = $%d",
strings.Join(setClauses, ", "),
argIdx,
)
args = append(args, groupID)
if _, err := h.db.Exec(c.Request.Context(), query, args...); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update settings: " + err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "settings updated"})
}

View file

@ -208,32 +208,6 @@ func (h *MediaHandler) putObjectS3(c *gin.Context, body io.ReadSeeker, contentLe
return key, nil
}
// GetSignedMediaURL resolves a relative R2 path to a fully-qualified URL.
// Flutter calls GET /media/sign?path=<key> for any path that was stored as a relative key.
func (h *MediaHandler) GetSignedMediaURL(c *gin.Context) {
path := c.Query("path")
if path == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "path query parameter is required"})
return
}
if strings.HasPrefix(path, "http") {
c.JSON(http.StatusOK, gin.H{"url": path})
return
}
domain := h.publicDomain
if strings.Contains(path, "videos/") {
domain = h.videoDomain
}
if domain == "" {
c.JSON(http.StatusOK, gin.H{"url": path})
return
}
if !strings.HasPrefix(domain, "http") {
domain = "https://" + domain
}
c.JSON(http.StatusOK, gin.H{"url": fmt.Sprintf("%s/%s", domain, path)})
}
func (h *MediaHandler) putObjectR2API(c *gin.Context, fileBytes []byte, contentType string, bucket string, key string, publicDomain string) (string, error) {
if h.accountID == "" || h.apiToken == "" {
return "", fmt.Errorf("R2 API credentials missing")
@ -268,69 +242,3 @@ func (h *MediaHandler) putObjectR2API(c *gin.Context, fileBytes []byte, contentT
return fmt.Sprintf("https://%s.r2.cloudflarestorage.com/%s/%s", h.accountID, bucket, key), nil
}
// ImageProxy streams an image from an external URL through the server so that
// the client's IP is never exposed to the origin (Reddit, GifCities, etc.).
// The image is streamed chunk-by-chunk and never written to disk or cached.
//
// Usage: GET /image-proxy?url=https%3A%2F%2Fi.redd.it%2Ffoo.gif
func (h *MediaHandler) ImageProxy(c *gin.Context) {
rawURL := c.Query("url")
if rawURL == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "url required"})
return
}
// Allowlist: only proxy from known GIF sources to prevent SSRF abuse
allowed := false
for _, prefix := range []string{
"https://i.redd.it/",
"https://preview.redd.it/",
"https://external-preview.redd.it/",
"https://blob.gifcities.org/gifcities/",
"https://i.imgur.com/",
"https://media.giphy.com/",
} {
if strings.HasPrefix(rawURL, prefix) {
allowed = true
break
}
}
if !allowed {
c.JSON(http.StatusForbidden, gin.H{"error": "origin not allowed"})
return
}
ctx := c.Request.Context()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid url"})
return
}
req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; Sojorn/1.0)")
client := &http.Client{Timeout: 20 * time.Second}
resp, err := client.Do(req)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "fetch failed"})
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
c.Status(resp.StatusCode)
return
}
contentType := resp.Header.Get("Content-Type")
if contentType == "" {
contentType = "image/gif"
}
c.Header("Content-Type", contentType)
c.Header("Cache-Control", "public, max-age=3600")
c.Status(http.StatusOK)
// Stream body directly to client — no buffering, no disk writes
io.Copy(c.Writer, resp.Body) //nolint:errcheck
}

View file

@ -617,27 +617,6 @@ func (h *NeighborhoodHandler) GetMyNeighborhood(c *gin.Context) {
},
}
// If the seed has no group yet, create one now (lazy creation)
if groupID == nil {
seed := &seedRow{
ID: seedID,
Name: name,
City: city,
State: state,
ZipCode: zipCode,
Country: country,
Lat: lat,
Lng: lng,
RadiusMeters: radiusMeters,
}
newGroupID, createErr := h.createNeighborhoodGroup(ctx, seed)
if createErr != nil {
log.Printf("[Neighborhood] GetMyNeighborhood lazy group creation error: %v", createErr)
} else {
groupID = &newGroupID
}
}
if groupID != nil {
var groupName string
var memberCount int

View file

@ -7,14 +7,13 @@ import (
"strings"
"time"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/rs/zerolog/log"
"gitlab.com/patrickbritton3/sojorn/go-backend/internal/models"
"gitlab.com/patrickbritton3/sojorn/go-backend/internal/repository"
"gitlab.com/patrickbritton3/sojorn/go-backend/internal/services"
"gitlab.com/patrickbritton3/sojorn/go-backend/pkg/utils"
"github.com/rs/zerolog/log"
)
type PostHandler struct {
@ -28,10 +27,9 @@ type PostHandler struct {
openRouterService *services.OpenRouterService
linkPreviewService *services.LinkPreviewService
localAIService *services.LocalAIService
videoProcessor *services.VideoProcessor
}
func NewPostHandler(postRepo *repository.PostRepository, userRepo *repository.UserRepository, feedService *services.FeedService, assetService *services.AssetService, notificationService *services.NotificationService, moderationService *services.ModerationService, contentFilter *services.ContentFilter, openRouterService *services.OpenRouterService, linkPreviewService *services.LinkPreviewService, localAIService *services.LocalAIService, s3Client *s3.Client, videoBucket, vidDomain string) *PostHandler {
func NewPostHandler(postRepo *repository.PostRepository, userRepo *repository.UserRepository, feedService *services.FeedService, assetService *services.AssetService, notificationService *services.NotificationService, moderationService *services.ModerationService, contentFilter *services.ContentFilter, openRouterService *services.OpenRouterService, linkPreviewService *services.LinkPreviewService, localAIService *services.LocalAIService) *PostHandler {
return &PostHandler{
postRepo: postRepo,
userRepo: userRepo,
@ -43,7 +41,6 @@ func NewPostHandler(postRepo *repository.PostRepository, userRepo *repository.Us
openRouterService: openRouterService,
linkPreviewService: linkPreviewService,
localAIService: localAIService,
videoProcessor: services.NewVideoProcessor(s3Client, videoBucket, vidDomain),
}
}
@ -515,7 +512,6 @@ func (h *PostHandler) CreatePost(c *gin.Context) {
IsNSFW bool `json:"is_nsfw"`
NSFWReason string `json:"nsfw_reason"`
Visibility string `json:"visibility"`
OverlayJSON *string `json:"overlay_json"`
}
if err := c.ShouldBindJSON(&req); err != nil {
@ -612,12 +608,11 @@ func (h *PostHandler) CreatePost(c *gin.Context) {
}
return "public"
}(),
ExpiresAt: expiresAt,
IsNSFW: req.IsNSFW,
NSFWReason: req.NSFWReason,
Lat: req.BeaconLat,
Long: req.BeaconLong,
OverlayJSON: req.OverlayJSON,
ExpiresAt: expiresAt,
IsNSFW: req.IsNSFW,
NSFWReason: req.NSFWReason,
Lat: req.BeaconLat,
Long: req.BeaconLong,
}
if req.CategoryID != nil {
@ -757,49 +752,22 @@ func (h *PostHandler) CreatePost(c *gin.Context) {
}
}
}
// Enhanced video moderation with frame extraction
if post.Status != "removed" && req.VideoURL != nil && *req.VideoURL != "" {
// First check thumbnail moderation
if req.Thumbnail != nil && *req.Thumbnail != "" {
vidResult, vidErr := h.openRouterService.ModerateImage(ctx, *req.Thumbnail)
if vidErr == nil && vidResult != nil {
log.Info().Str("action", vidResult.Action).Msg("OpenRouter video thumbnail moderation")
if vidResult.Action == "flag" {
orDecision = "flag"
post.Status = "removed"
} else if vidResult.Action == "nsfw" && orDecision != "flag" {
orDecision = "nsfw"
post.IsNSFW = true
if vidResult.NSFWReason != "" {
post.NSFWReason = vidResult.NSFWReason
}
// Video thumbnail moderation
if post.Status != "removed" && req.VideoURL != nil && *req.VideoURL != "" && req.Thumbnail != nil && *req.Thumbnail != "" {
vidResult, vidErr := h.openRouterService.ModerateImage(ctx, *req.Thumbnail)
if vidErr == nil && vidResult != nil {
log.Info().Str("action", vidResult.Action).Msg("OpenRouter video thumbnail moderation")
if vidResult.Action == "flag" {
orDecision = "flag"
post.Status = "removed"
} else if vidResult.Action == "nsfw" && orDecision != "flag" {
orDecision = "nsfw"
post.IsNSFW = true
if vidResult.NSFWReason != "" {
post.NSFWReason = vidResult.NSFWReason
}
}
}
// Extract and analyze video frames for deeper moderation
if post.Status != "removed" && h.videoProcessor != nil {
frameURLs, err := h.videoProcessor.ExtractFrames(ctx, *req.VideoURL, 3)
if err == nil && len(frameURLs) > 0 {
// Analyze extracted frames with Azure OpenAI Vision
if h.moderationService != nil {
_, frameReason, frameErr := h.moderationService.AnalyzeContent(ctx, "Video frame analysis", frameURLs)
if frameErr == nil && frameReason != "" {
log.Info().Str("reason", frameReason).Msg("Video frame analysis completed")
if strings.Contains(strings.ToLower(frameReason), "flag") || strings.Contains(strings.ToLower(frameReason), "remove") {
orDecision = "flag"
post.Status = "removed"
} else if strings.Contains(strings.ToLower(frameReason), "nsfw") && orDecision != "flag" {
orDecision = "nsfw"
post.IsNSFW = true
post.NSFWReason = "Video content flagged by frame analysis"
}
}
}
} else {
log.Debug().Err(err).Msg("Failed to extract video frames for moderation")
}
}
}
}
@ -1170,22 +1138,6 @@ func (h *PostHandler) UnlikePost(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "Post unliked"})
}
// HidePost records a "Not Interested" signal for a post.
// The post will be excluded from all subsequent feed queries for this user,
// and repeated hides of the same author trigger algorithmic suppression.
func (h *PostHandler) HidePost(c *gin.Context) {
postID := c.Param("id")
userIDStr, _ := c.Get("user_id")
err := h.postRepo.HidePost(c.Request.Context(), postID, userIDStr.(string))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to hide post", "details": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Post hidden"})
}
func (h *PostHandler) SavePost(c *gin.Context) {
postID := c.Param("id")
userIDStr, _ := c.Get("user_id")

View file

@ -1,111 +0,0 @@
package handlers
import (
"encoding/json"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/jackc/pgx/v5/pgxpool"
)
type ProfileLayoutHandler struct {
db *pgxpool.Pool
}
func NewProfileLayoutHandler(db *pgxpool.Pool) *ProfileLayoutHandler {
return &ProfileLayoutHandler{db: db}
}
// GetProfileLayout — GET /profile/layout
func (h *ProfileLayoutHandler) GetProfileLayout(c *gin.Context) {
userID, _ := c.Get("user_id")
userIDStr := userID.(string)
var widgetsJSON []byte
var theme string
var accentColor, bannerImageURL *string
var updatedAt time.Time
err := h.db.QueryRow(c.Request.Context(), `
SELECT widgets, theme, accent_color, banner_image_url, updated_at
FROM profile_layouts
WHERE user_id = $1
`, userIDStr).Scan(&widgetsJSON, &theme, &accentColor, &bannerImageURL, &updatedAt)
if err != nil {
// No layout yet — return empty default
c.JSON(http.StatusOK, gin.H{
"widgets": []interface{}{},
"theme": "default",
"accent_color": nil,
"banner_image_url": nil,
"updated_at": time.Now().Format(time.RFC3339),
})
return
}
var widgets interface{}
if err := json.Unmarshal(widgetsJSON, &widgets); err != nil {
widgets = []interface{}{}
}
c.JSON(http.StatusOK, gin.H{
"widgets": widgets,
"theme": theme,
"accent_color": accentColor,
"banner_image_url": bannerImageURL,
"updated_at": updatedAt.Format(time.RFC3339),
})
}
// SaveProfileLayout — PUT /profile/layout
func (h *ProfileLayoutHandler) SaveProfileLayout(c *gin.Context) {
userID, _ := c.Get("user_id")
userIDStr := userID.(string)
var req struct {
Widgets interface{} `json:"widgets"`
Theme string `json:"theme"`
AccentColor *string `json:"accent_color"`
BannerImageURL *string `json:"banner_image_url"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.Theme == "" {
req.Theme = "default"
}
widgetsJSON, err := json.Marshal(req.Widgets)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid widgets format"})
return
}
now := time.Now()
_, err = h.db.Exec(c.Request.Context(), `
INSERT INTO profile_layouts (user_id, widgets, theme, accent_color, banner_image_url, updated_at)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (user_id) DO UPDATE SET
widgets = EXCLUDED.widgets,
theme = EXCLUDED.theme,
accent_color = EXCLUDED.accent_color,
banner_image_url = EXCLUDED.banner_image_url,
updated_at = EXCLUDED.updated_at
`, userIDStr, widgetsJSON, req.Theme, req.AccentColor, req.BannerImageURL, now)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save layout"})
return
}
c.JSON(http.StatusOK, gin.H{
"widgets": req.Widgets,
"theme": req.Theme,
"accent_color": req.AccentColor,
"banner_image_url": req.BannerImageURL,
"updated_at": now.Format(time.RFC3339),
})
}

View file

@ -1,502 +0,0 @@
package handlers
import (
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
type RepostHandler struct {
db *pgxpool.Pool
}
func NewRepostHandler(db *pgxpool.Pool) *RepostHandler {
return &RepostHandler{db: db}
}
// CreateRepost — POST /posts/repost
func (h *RepostHandler) CreateRepost(c *gin.Context) {
userID, _ := c.Get("user_id")
userIDStr := userID.(string)
var req struct {
OriginalPostID string `json:"original_post_id" binding:"required"`
Type string `json:"type" binding:"required"`
Comment string `json:"comment"`
Metadata map[string]interface{} `json:"metadata"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
validTypes := map[string]bool{"standard": true, "quote": true, "boost": true, "amplify": true}
if !validTypes[req.Type] {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid repost type"})
return
}
var authorHandle string
var avatarURL *string
err := h.db.QueryRow(c.Request.Context(),
"SELECT handle, avatar_url FROM profiles WHERE id = $1", userIDStr,
).Scan(&authorHandle, &avatarURL)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get user info"})
return
}
id := uuid.New().String()
now := time.Now()
_, err = h.db.Exec(c.Request.Context(), `
INSERT INTO reposts (id, original_post_id, author_id, type, comment, metadata, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (original_post_id, author_id, type) DO NOTHING
`, id, req.OriginalPostID, userIDStr, req.Type, req.Comment, req.Metadata, now)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create repost"})
return
}
countCol := repostCountColumn(req.Type)
h.db.Exec(c.Request.Context(),
"UPDATE posts SET "+countCol+" = "+countCol+" + 1 WHERE id = $1",
req.OriginalPostID)
c.JSON(http.StatusOK, gin.H{
"success": true,
"repost": gin.H{
"id": id,
"original_post_id": req.OriginalPostID,
"author_id": userIDStr,
"author_handle": authorHandle,
"author_avatar": avatarURL,
"type": req.Type,
"comment": req.Comment,
"created_at": now.Format(time.RFC3339),
"boost_count": 0,
"amplification_score": 0,
"is_amplified": false,
},
})
}
// BoostPost — POST /posts/boost
func (h *RepostHandler) BoostPost(c *gin.Context) {
userID, _ := c.Get("user_id")
userIDStr := userID.(string)
var req struct {
PostID string `json:"post_id" binding:"required"`
BoostType string `json:"boost_type" binding:"required"`
BoostAmount int `json:"boost_amount"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.BoostAmount <= 0 {
req.BoostAmount = 1
}
maxDaily := 5
if req.BoostType == "amplify" {
maxDaily = 3
}
var dailyCount int
h.db.QueryRow(c.Request.Context(), `
SELECT COUNT(*) FROM reposts
WHERE author_id = $1 AND type = $2 AND created_at > NOW() - INTERVAL '24 hours'
`, userIDStr, req.BoostType).Scan(&dailyCount)
if dailyCount >= maxDaily {
c.JSON(http.StatusTooManyRequests, gin.H{"error": "daily boost limit reached", "success": false})
return
}
id := uuid.New().String()
_, err := h.db.Exec(c.Request.Context(), `
INSERT INTO reposts (id, original_post_id, author_id, type, created_at)
VALUES ($1, $2, $3, $4, NOW())
ON CONFLICT (original_post_id, author_id, type) DO NOTHING
`, id, req.PostID, userIDStr, req.BoostType)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to boost post"})
return
}
countCol := repostCountColumn(req.BoostType)
h.db.Exec(c.Request.Context(),
"UPDATE posts SET "+countCol+" = "+countCol+" + 1 WHERE id = $1",
req.PostID)
c.JSON(http.StatusOK, gin.H{"success": true})
}
// GetRepostsForPost — GET /posts/:id/reposts
func (h *RepostHandler) GetRepostsForPost(c *gin.Context) {
postID := c.Param("id")
limit := clampInt(queryInt(c, "limit", 20), 1, 100)
rows, err := h.db.Query(c.Request.Context(), `
SELECT r.id, r.original_post_id, r.author_id,
p.handle, p.avatar_url,
r.type, r.comment, r.created_at
FROM reposts r
JOIN profiles p ON p.id = r.author_id
WHERE r.original_post_id = $1
ORDER BY r.created_at DESC
LIMIT $2
`, postID, limit)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get reposts"})
return
}
defer rows.Close()
reposts := buildRepostList(rows)
c.JSON(http.StatusOK, gin.H{"success": true, "reposts": reposts})
}
// GetUserReposts — GET /users/:id/reposts
func (h *RepostHandler) GetUserReposts(c *gin.Context) {
userID := c.Param("id")
limit := clampInt(queryInt(c, "limit", 20), 1, 100)
rows, err := h.db.Query(c.Request.Context(), `
SELECT r.id, r.original_post_id, r.author_id,
p.handle, p.avatar_url,
r.type, r.comment, r.created_at
FROM reposts r
JOIN profiles p ON p.id = r.author_id
WHERE r.author_id = $1
ORDER BY r.created_at DESC
LIMIT $2
`, userID, limit)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get user reposts"})
return
}
defer rows.Close()
reposts := buildRepostList(rows)
c.JSON(http.StatusOK, gin.H{"success": true, "reposts": reposts})
}
// DeleteRepost — DELETE /reposts/:id
func (h *RepostHandler) DeleteRepost(c *gin.Context) {
userID, _ := c.Get("user_id")
repostID := c.Param("id")
var origPostID, repostType string
err := h.db.QueryRow(c.Request.Context(),
"SELECT original_post_id, type FROM reposts WHERE id = $1 AND author_id = $2",
repostID, userID.(string),
).Scan(&origPostID, &repostType)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "repost not found"})
return
}
_, err = h.db.Exec(c.Request.Context(),
"DELETE FROM reposts WHERE id = $1 AND author_id = $2",
repostID, userID.(string))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete repost"})
return
}
countCol := repostCountColumn(repostType)
h.db.Exec(c.Request.Context(),
"UPDATE posts SET "+countCol+" = GREATEST("+countCol+" - 1, 0) WHERE id = $1",
origPostID)
c.JSON(http.StatusOK, gin.H{"success": true})
}
// GetAmplificationAnalytics — GET /posts/:id/amplification
func (h *RepostHandler) GetAmplificationAnalytics(c *gin.Context) {
postID := c.Param("id")
var totalAmplification int
h.db.QueryRow(c.Request.Context(),
"SELECT COUNT(*) FROM reposts WHERE original_post_id = $1", postID,
).Scan(&totalAmplification)
var viewCount int
h.db.QueryRow(c.Request.Context(),
"SELECT COALESCE(view_count, 1) FROM posts WHERE id = $1", postID,
).Scan(&viewCount)
if viewCount == 0 {
viewCount = 1
}
amplificationRate := float64(totalAmplification) / float64(viewCount)
rows, _ := h.db.Query(c.Request.Context(),
"SELECT type, COUNT(*) FROM reposts WHERE original_post_id = $1 GROUP BY type", postID)
repostCounts := map[string]int{}
if rows != nil {
defer rows.Close()
for rows.Next() {
var t string
var cnt int
rows.Scan(&t, &cnt)
repostCounts[t] = cnt
}
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"analytics": gin.H{
"post_id": postID,
"metrics": []gin.H{},
"reposts": []gin.H{},
"total_amplification": totalAmplification,
"amplification_rate": amplificationRate,
"repost_counts": repostCounts,
},
})
}
// GetTrendingPosts — GET /posts/trending
func (h *RepostHandler) GetTrendingPosts(c *gin.Context) {
limit := clampInt(queryInt(c, "limit", 10), 1, 50)
category := c.Query("category")
query := `
SELECT p.id
FROM posts p
WHERE p.status = 'active'
AND p.deleted_at IS NULL
`
args := []interface{}{}
argIdx := 1
if category != "" {
query += " AND p.category = $" + strconv.Itoa(argIdx)
args = append(args, category)
argIdx++
}
query += `
ORDER BY (
COALESCE(p.like_count, 0) * 1 +
COALESCE(p.comment_count, 0) * 3 +
COALESCE(p.repost_count, 0) * 4 +
COALESCE(p.boost_count, 0) * 8 +
COALESCE(p.amplify_count, 0) * 10
) DESC, p.created_at DESC
LIMIT $` + strconv.Itoa(argIdx)
args = append(args, limit)
rows, err := h.db.Query(c.Request.Context(), query, args...)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get trending posts"})
return
}
defer rows.Close()
var postIDs []string
for rows.Next() {
var id string
rows.Scan(&id)
postIDs = append(postIDs, id)
}
if postIDs == nil {
postIDs = []string{}
}
c.JSON(http.StatusOK, gin.H{"success": true, "posts": postIDs})
}
// GetAmplificationRules — GET /amplification/rules
func (h *RepostHandler) GetAmplificationRules(c *gin.Context) {
rules := []gin.H{
{
"id": "rule-standard", "name": "Standard Repost",
"description": "Share a post with your followers",
"type": "standard", "weight_multiplier": 1.0,
"min_boost_score": 0, "max_daily_boosts": 20,
"is_active": true, "created_at": "2024-01-01T00:00:00Z",
},
{
"id": "rule-quote", "name": "Quote Repost",
"description": "Share a post with your commentary",
"type": "quote", "weight_multiplier": 1.5,
"min_boost_score": 0, "max_daily_boosts": 10,
"is_active": true, "created_at": "2024-01-01T00:00:00Z",
},
{
"id": "rule-boost", "name": "Boost",
"description": "Amplify a post's reach in the feed",
"type": "boost", "weight_multiplier": 8.0,
"min_boost_score": 0, "max_daily_boosts": 5,
"is_active": true, "created_at": "2024-01-01T00:00:00Z",
},
{
"id": "rule-amplify", "name": "Amplify",
"description": "Maximum amplification for high-quality content",
"type": "amplify", "weight_multiplier": 10.0,
"min_boost_score": 100, "max_daily_boosts": 3,
"is_active": true, "created_at": "2024-01-01T00:00:00Z",
},
}
c.JSON(http.StatusOK, gin.H{"success": true, "rules": rules})
}
// CalculateAmplificationScore — POST /posts/:id/calculate-score
func (h *RepostHandler) CalculateAmplificationScore(c *gin.Context) {
postID := c.Param("id")
var likes, comments, reposts, boosts, amplifies int
h.db.QueryRow(c.Request.Context(), `
SELECT COALESCE(like_count,0), COALESCE(comment_count,0),
COALESCE(repost_count,0), COALESCE(boost_count,0), COALESCE(amplify_count,0)
FROM posts WHERE id = $1
`, postID).Scan(&likes, &comments, &reposts, &boosts, &amplifies)
score := likes*1 + comments*3 + reposts*4 + boosts*8 + amplifies*10
c.JSON(http.StatusOK, gin.H{"success": true, "score": score})
}
// CanBoostPost — GET /users/:id/can-boost/:postId
func (h *RepostHandler) CanBoostPost(c *gin.Context) {
userID := c.Param("id")
postID := c.Param("postId")
boostType := c.Query("type")
var alreadyBoosted int
h.db.QueryRow(c.Request.Context(),
"SELECT COUNT(*) FROM reposts WHERE author_id=$1 AND original_post_id=$2 AND type=$3",
userID, postID, boostType,
).Scan(&alreadyBoosted)
if alreadyBoosted > 0 {
c.JSON(http.StatusOK, gin.H{"can_boost": false, "reason": "already_boosted"})
return
}
maxDaily := 5
if boostType == "amplify" {
maxDaily = 3
}
var dailyCount int
h.db.QueryRow(c.Request.Context(), `
SELECT COUNT(*) FROM reposts
WHERE author_id=$1 AND type=$2 AND created_at > NOW() - INTERVAL '24 hours'
`, userID, boostType).Scan(&dailyCount)
c.JSON(http.StatusOK, gin.H{"can_boost": dailyCount < maxDaily})
}
// GetDailyBoostCount — GET /users/:id/daily-boosts
func (h *RepostHandler) GetDailyBoostCount(c *gin.Context) {
userID := c.Param("id")
rows, err := h.db.Query(c.Request.Context(), `
SELECT type, COUNT(*) FROM reposts
WHERE author_id=$1 AND created_at > NOW() - INTERVAL '24 hours'
GROUP BY type
`, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get boost counts"})
return
}
defer rows.Close()
boostCounts := map[string]int{}
for rows.Next() {
var t string
var cnt int
rows.Scan(&t, &cnt)
boostCounts[t] = cnt
}
c.JSON(http.StatusOK, gin.H{"success": true, "boost_counts": boostCounts})
}
// ReportRepost — POST /reposts/:id/report
func (h *RepostHandler) ReportRepost(c *gin.Context) {
userID, _ := c.Get("user_id")
repostID := c.Param("id")
var req struct {
Reason string `json:"reason" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
_, err := h.db.Exec(c.Request.Context(), `
INSERT INTO repost_reports (id, repost_id, reporter_id, reason, created_at)
VALUES ($1, $2, $3, $4, NOW())
ON CONFLICT (repost_id, reporter_id) DO NOTHING
`, uuid.New().String(), repostID, userID.(string), req.Reason)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to report repost"})
return
}
c.JSON(http.StatusOK, gin.H{"success": true})
}
// ─── helpers ─────────────────────────────────────────────────────────────────
func repostCountColumn(repostType string) string {
switch repostType {
case "boost":
return "boost_count"
case "amplify":
return "amplify_count"
default:
return "repost_count"
}
}
func queryInt(c *gin.Context, key string, def int) int {
if s := c.Query(key); s != "" {
if n, err := strconv.Atoi(s); err == nil {
return n
}
}
return def
}
func clampInt(v, min, max int) int {
if v < min {
return min
}
if v > max {
return max
}
return v
}
func buildRepostList(rows interface {
Next() bool
Scan(...interface{}) error
Close()
}) []gin.H {
list := []gin.H{}
for rows.Next() {
var id, origPostID, authorID, handle, repostType string
var avatarURL, comment *string
var createdAt time.Time
rows.Scan(&id, &origPostID, &authorID, &handle, &avatarURL, &repostType, &comment, &createdAt)
list = append(list, gin.H{
"id": id,
"original_post_id": origPostID,
"author_id": authorID,
"author_handle": handle,
"author_avatar": avatarURL,
"type": repostType,
"comment": comment,
"created_at": createdAt.Format(time.RFC3339),
"boost_count": 0,
"amplification_score": 0,
"is_amplified": false,
})
}
return list
}

View file

@ -609,76 +609,6 @@ func (h *UserHandler) GetCircleMembers(c *gin.Context) {
// Data Export (Portability)
// ========================================================================
// ========================================================================
// Block list bulk import
// ========================================================================
// BulkBlockUsers POST /users/me/blocks/bulk
// Body: {"handles": ["alice", "bob", ...]}
// Blocks each handle, auto-unfollows both ways.
func (h *UserHandler) BulkBlockUsers(c *gin.Context) {
actorID, _ := c.Get("user_id")
actorIP := c.ClientIP()
var req struct {
Handles []string `json:"handles" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if len(req.Handles) > 500 {
c.JSON(http.StatusBadRequest, gin.H{"error": "maximum 500 handles per request"})
return
}
var blocked int
var notFound []string
var alreadyBlocked []string
for _, handle := range req.Handles {
handle = strings.TrimSpace(strings.TrimPrefix(handle, "@"))
if handle == "" {
continue
}
err := h.repo.BlockUserByHandle(c.Request.Context(), actorID.(string), handle, actorIP)
if err != nil {
msg := err.Error()
if strings.Contains(msg, "not found") {
notFound = append(notFound, handle)
} else if strings.Contains(msg, "conflict") || strings.Contains(msg, "duplicate") {
alreadyBlocked = append(alreadyBlocked, handle)
} else {
log.Warn().Err(err).Str("handle", handle).Msg("bulk block: unexpected error")
}
continue
}
blocked++
}
c.JSON(http.StatusOK, gin.H{
"blocked": blocked,
"not_found": notFound,
"already_blocked": alreadyBlocked,
})
}
// GetUserByHandle resolves a public profile by @handle.
// Used by the capsule invite flow so the client can look up a user's public key before encrypting.
func (h *UserHandler) GetUserByHandle(c *gin.Context) {
handle := strings.TrimPrefix(strings.TrimSpace(c.Param("handle")), "@")
if handle == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "handle is required"})
return
}
profile, err := h.repo.GetProfileByHandle(c.Request.Context(), handle)
if err != nil || profile == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
return
}
c.JSON(http.StatusOK, profile)
}
// ExportData streams user data as JSON for portability/GDPR compliance
func (h *UserHandler) ExportData(c *gin.Context) {
userID, _ := c.Get("user_id")

View file

@ -15,6 +15,7 @@ import (
func ParseToken(tokenString string, jwtSecret string) (string, jwt.MapClaims, error) {
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
// Validate the algorithm (Supabase uses HS256 usually)
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
@ -30,6 +31,7 @@ func ParseToken(tokenString string, jwtSecret string) (string, jwt.MapClaims, er
return "", nil, fmt.Errorf("invalid token claims")
}
// Supabase uses 'sub' field for user ID
userID, ok := claims["sub"].(string)
if !ok {
return "", nil, fmt.Errorf("token missing user ID")

View file

@ -39,9 +39,6 @@ type Post struct {
NSFWReason string `json:"nsfw_reason" db:"nsfw_reason"`
ExpiresAt *time.Time `json:"expires_at" db:"expires_at"`
// Quip overlay JSON — stores text/sticker decorations as client-rendered widgets
OverlayJSON *string `json:"overlay_json,omitempty" db:"overlay_json"`
// Link preview (populated via enrichment, not in every query)
LinkPreviewURL *string `json:"link_preview_url,omitempty" db:"link_preview_url"`
LinkPreviewTitle *string `json:"link_preview_title,omitempty" db:"link_preview_title"`

View file

@ -1,479 +0,0 @@
package monitoring
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"net/http"
"runtime"
"sync"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
)
type HealthCheckService struct {
db *pgxpool.Pool
httpClient *http.Client
checks map[string]HealthCheck
mutex sync.RWMutex
startTime time.Time
}
type HealthCheck struct {
Name string `json:"name"`
Status string `json:"status"`
Message string `json:"message"`
Duration time.Duration `json:"duration"`
Timestamp time.Time `json:"timestamp"`
Details map[string]interface{} `json:"details,omitempty"`
}
type HealthStatus struct {
Status string `json:"status"`
Timestamp time.Time `json:"timestamp"`
Uptime time.Duration `json:"uptime"`
Version string `json:"version"`
Environment string `json:"environment"`
Checks map[string]HealthCheck `json:"checks"`
System SystemInfo `json:"system"`
}
type SystemInfo struct {
GoVersion string `json:"go_version"`
NumGoroutine int `json:"num_goroutine"`
MemoryUsage MemInfo `json:"memory_usage"`
NumCPU int `json:"num_cpu"`
}
type MemInfo struct {
Alloc uint64 `json:"alloc"`
TotalAlloc uint64 `json:"total_alloc"`
Sys uint64 `json:"sys"`
NumGC uint32 `json:"num_gc"`
}
type AlertLevel string
const (
AlertLevelInfo AlertLevel = "info"
AlertLevelWarning AlertLevel = "warning"
AlertLevelError AlertLevel = "error"
AlertLevelCritical AlertLevel = "critical"
)
type Alert struct {
Level AlertLevel `json:"level"`
Service string `json:"service"`
Message string `json:"message"`
Timestamp time.Time `json:"timestamp"`
Details map[string]interface{} `json:"details,omitempty"`
}
func NewHealthCheckService(db *pgxpool.Pool) *HealthCheckService {
return &HealthCheckService{
db: db,
httpClient: &http.Client{Timeout: 10 * time.Second},
checks: make(map[string]HealthCheck),
startTime: time.Now(),
}
}
// Run all health checks
func (s *HealthCheckService) RunHealthChecks(ctx context.Context) HealthStatus {
s.mutex.Lock()
defer s.mutex.Unlock()
checks := make(map[string]HealthCheck)
// Database health check
checks["database"] = s.checkDatabase(ctx)
// External service checks
checks["azure_openai"] = s.checkAzureOpenAI(ctx)
checks["cloudflare_r2"] = s.checkCloudflareR2(ctx)
// Internal service checks
checks["api_server"] = s.checkAPIServer(ctx)
checks["auth_service"] = s.checkAuthService(ctx)
// System checks
checks["memory"] = s.checkMemoryUsage()
checks["disk_space"] = s.checkDiskSpace()
// Determine overall status
overallStatus := "healthy"
for _, check := range checks {
if check.Status == "unhealthy" {
overallStatus = "unhealthy"
break
} else if check.Status == "degraded" && overallStatus == "healthy" {
overallStatus = "degraded"
}
}
return HealthStatus{
Status: overallStatus,
Timestamp: time.Now(),
Uptime: time.Since(s.startTime),
Version: "1.0.0", // This should come from build info
Environment: "production", // This should come from config
Checks: checks,
System: s.getSystemInfo(),
}
}
// Database health check
func (s *HealthCheckService) checkDatabase(ctx context.Context) HealthCheck {
start := time.Now()
check := HealthCheck{
Name: "database",
Timestamp: start,
}
// Test database connection
var result sql.NullString
err := s.db.QueryRow(ctx, "SELECT 'healthy' as status").Scan(&result)
if err != nil {
check.Status = "unhealthy"
check.Message = fmt.Sprintf("Database connection failed: %v", err)
check.Duration = time.Since(start)
return check
}
// Check database stats
var connectionCount int
err = s.db.QueryRow(ctx, "SELECT count(*) FROM pg_stat_activity").Scan(&connectionCount)
check.Status = "healthy"
check.Message = "Database connection successful"
check.Duration = time.Since(start)
check.Details = map[string]interface{}{
"connection_count": connectionCount,
"status": result.String,
}
return check
}
// Azure OpenAI health check
func (s *HealthCheckService) checkAzureOpenAI(ctx context.Context) HealthCheck {
start := time.Now()
check := HealthCheck{
Name: "azure_openai",
Timestamp: start,
}
// Create a simple test request
req, err := http.NewRequestWithContext(ctx, "GET", "https://api.openai.com/v1/models", nil)
if err != nil {
check.Status = "unhealthy"
check.Message = fmt.Sprintf("Failed to create request: %v", err)
check.Duration = time.Since(start)
return check
}
// Add authorization header (this should come from config)
req.Header.Set("Authorization", "Bearer test-key")
resp, err := s.httpClient.Do(req)
if err != nil {
check.Status = "unhealthy"
check.Message = fmt.Sprintf("Request failed: %v", err)
check.Duration = time.Since(start)
return check
}
defer resp.Body.Close()
if resp.StatusCode == 200 {
check.Status = "healthy"
check.Message = "Azure OpenAI service is responsive"
} else if resp.StatusCode >= 400 && resp.StatusCode < 500 {
check.Status = "degraded"
check.Message = fmt.Sprintf("Azure OpenAI returned status %d", resp.StatusCode)
} else {
check.Status = "unhealthy"
check.Message = fmt.Sprintf("Azure OpenAI returned status %d", resp.StatusCode)
}
check.Duration = time.Since(start)
check.Details = map[string]interface{}{
"status_code": resp.StatusCode,
}
return check
}
// Cloudflare R2 health check
func (s *HealthCheckService) checkCloudflareR2(ctx context.Context) HealthCheck {
start := time.Now()
check := HealthCheck{
Name: "cloudflare_r2",
Timestamp: start,
}
// Test R2 connectivity (this would be a real R2 API call)
// For now, we'll simulate the check
time.Sleep(100 * time.Millisecond) // Simulate network latency
check.Status = "healthy"
check.Message = "Cloudflare R2 service is accessible"
check.Duration = time.Since(start)
check.Details = map[string]interface{}{
"endpoint": "https://your-account.r2.cloudflarestorage.com",
"latency_ms": check.Duration.Milliseconds(),
}
return check
}
// API server health check
func (s *HealthCheckService) checkAPIServer(ctx context.Context) HealthCheck {
start := time.Now()
check := HealthCheck{
Name: "api_server",
Timestamp: start,
}
// Test internal API endpoint
req, err := http.NewRequestWithContext(ctx, "GET", "http://localhost:8080/health", nil)
if err != nil {
check.Status = "unhealthy"
check.Message = fmt.Sprintf("Failed to create API request: %v", err)
check.Duration = time.Since(start)
return check
}
resp, err := s.httpClient.Do(req)
if err != nil {
check.Status = "unhealthy"
check.Message = fmt.Sprintf("API request failed: %v", err)
check.Duration = time.Since(start)
return check
}
defer resp.Body.Close()
if resp.StatusCode == 200 {
check.Status = "healthy"
check.Message = "API server is responding"
} else {
check.Status = "unhealthy"
check.Message = fmt.Sprintf("API server returned status %d", resp.StatusCode)
}
check.Duration = time.Since(start)
check.Details = map[string]interface{}{
"status_code": resp.StatusCode,
}
return check
}
// Auth service health check
func (s *HealthCheckService) checkAuthService(ctx context.Context) HealthCheck {
start := time.Now()
check := HealthCheck{
Name: "auth_service",
Timestamp: start,
}
// Test auth service (this would be a real auth service check)
// For now, we'll simulate the check
time.Sleep(50 * time.Millisecond)
check.Status = "healthy"
check.Message = "Auth service is operational"
check.Duration = time.Since(start)
check.Details = map[string]interface{}{
"jwt_validation": "working",
"token_refresh": "working",
}
return check
}
// Memory usage check
func (s *HealthCheckService) checkMemoryUsage() HealthCheck {
start := time.Now()
check := HealthCheck{
Name: "memory",
Timestamp: start,
}
var m runtime.MemStats
runtime.ReadMemStats(&m)
// Check memory usage (threshold: 80% of available memory)
memoryUsageMB := m.Alloc / 1024 / 1024
thresholdMB := uint64(1024) // 1GB threshold
check.Status = "healthy"
check.Message = "Memory usage is normal"
if memoryUsageMB > thresholdMB {
check.Status = "degraded"
check.Message = "Memory usage is high"
}
check.Duration = time.Since(start)
check.Details = map[string]interface{}{
"alloc_mb": memoryUsageMB,
"total_alloc_mb": m.TotalAlloc / 1024 / 1024,
"sys_mb": m.Sys / 1024 / 1024,
"num_gc": m.NumGC,
"threshold_mb": thresholdMB,
}
return check
}
// Disk space check
func (s *HealthCheckService) checkDiskSpace() HealthCheck {
start := time.Now()
check := HealthCheck{
Name: "disk_space",
Timestamp: start,
}
// This would check actual disk space
// For now, we'll simulate the check
diskUsagePercent := 45.0 // Simulated disk usage
check.Status = "healthy"
check.Message = "Disk space is sufficient"
if diskUsagePercent > 80 {
check.Status = "degraded"
check.Message = "Disk space is low"
} else if diskUsagePercent > 90 {
check.Status = "unhealthy"
check.Message = "Disk space is critically low"
}
check.Duration = time.Since(start)
check.Details = map[string]interface{}{
"usage_percent": diskUsagePercent,
"available_gb": 55.0, // Simulated
"total_gb": 100.0, // Simulated
}
return check
}
// Get system information
func (s *HealthCheckService) getSystemInfo() SystemInfo {
var m runtime.MemStats
runtime.ReadMemStats(&m)
return SystemInfo{
GoVersion: runtime.Version(),
NumGoroutine: runtime.NumGoroutine(),
MemoryUsage: MemInfo{
Alloc: m.Alloc,
TotalAlloc: m.TotalAlloc,
Sys: m.Sys,
NumGC: m.NumGC,
},
NumCPU: runtime.NumCPU(),
}
}
// Send alert if needed
func (s *HealthCheckService) sendAlert(ctx context.Context, level AlertLevel, service, message string, details map[string]interface{}) {
alert := Alert{
Level: level,
Service: service,
Message: message,
Timestamp: time.Now(),
Details: details,
}
// Log the alert
logLevel := zerolog.InfoLevel
switch level {
case AlertLevelWarning:
logLevel = zerolog.WarnLevel
case AlertLevelError:
logLevel = zerolog.ErrorLevel
case AlertLevelCritical:
logLevel = zerolog.FatalLevel
}
log.WithLevel(logLevel).
Str("service", service).
Str("message", message).
Interface("details", details).
Msg("Health check alert")
// Here you would send to external monitoring service
// e.g., PagerDuty, Slack, email, etc.
s.sendToMonitoringService(ctx, alert)
}
// Send to external monitoring service
func (s *HealthCheckService) sendToMonitoringService(ctx context.Context, alert Alert) {
// This would integrate with your monitoring service
// For now, we'll just log it
alertJSON, _ := json.Marshal(alert)
log.Info().Str("alert", string(alertJSON)).Msg("Sending to monitoring service")
}
// Get health check history
func (s *HealthCheckService) GetHealthHistory(ctx context.Context, duration time.Duration) ([]HealthStatus, error) {
// This would retrieve health check history from database or cache
// For now, return empty slice
return []HealthStatus{}, nil
}
// HTTP handler for health checks
func (s *HealthCheckService) HealthCheckHandler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
health := s.RunHealthChecks(ctx)
w.Header().Set("Content-Type", "application/json")
if health.Status == "healthy" {
w.WriteHeader(http.StatusOK)
} else if health.Status == "degraded" {
w.WriteHeader(http.StatusOK) // Still 200 but with degraded status
} else {
w.WriteHeader(http.StatusServiceUnavailable)
}
json.NewEncoder(w).Encode(health)
}
// HTTP handler for readiness checks
func (s *HealthCheckService) ReadinessHandler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Check critical services only
dbCheck := s.checkDatabase(ctx)
if dbCheck.Status == "healthy" {
w.WriteHeader(http.StatusOK)
w.Write([]byte("ready"))
} else {
w.WriteHeader(http.StatusServiceUnavailable)
w.Write([]byte("not ready"))
}
}
// HTTP handler for liveness checks
func (s *HealthCheckService) LivenessHandler(w http.ResponseWriter, r *http.Request) {
// Simple liveness check - if we're running, we're alive
w.WriteHeader(http.StatusOK)
w.Write([]byte("alive"))
}

View file

@ -59,7 +59,7 @@ func (r *PostRepository) CreatePost(ctx context.Context, post *models.Post) erro
is_beacon, beacon_type, location, confidence_score,
is_active_beacon, allow_chain, chain_parent_id, visibility, expires_at,
is_nsfw, nsfw_reason,
severity, incident_status, radius, overlay_json
severity, incident_status, radius
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13,
$14, $15,
@ -68,7 +68,7 @@ func (r *PostRepository) CreatePost(ctx context.Context, post *models.Post) erro
ELSE NULL END,
$18, $19, $20, $21, $22, $23,
$24, $25,
$26, $27, $28, $29
$26, $27, $28
) RETURNING id, created_at
`
@ -84,7 +84,7 @@ func (r *PostRepository) CreatePost(ctx context.Context, post *models.Post) erro
post.IsBeacon, post.BeaconType, post.Lat, post.Long, post.Confidence,
post.IsActiveBeacon, post.AllowChain, post.ChainParentID, post.Visibility, post.ExpiresAt,
post.IsNSFW, post.NSFWReason,
post.Severity, post.IncidentStatus, post.Radius, post.OverlayJSON,
post.Severity, post.IncidentStatus, post.Radius,
).Scan(&post.ID, &post.CreatedAt)
if err != nil {
@ -168,8 +168,7 @@ func (r *PostRepository) GetFeed(ctx context.Context, userID string, categorySlu
CASE WHEN ($4::text) != '' THEN COALESCE((SELECT jsonb_agg(emoji) FROM public.post_reactions WHERE post_id = p.id AND user_id = $4::text::uuid), '[]'::jsonb) ELSE '[]'::jsonb END as my_reactions,
COALESCE(p.is_nsfw, FALSE) as is_nsfw,
COALESCE(p.nsfw_reason, '') as nsfw_reason,
p.link_preview_url, p.link_preview_title, p.link_preview_description, p.link_preview_image_url, p.link_preview_site_name,
p.overlay_json
p.link_preview_url, p.link_preview_title, p.link_preview_description, p.link_preview_image_url, p.link_preview_site_name
FROM public.posts p
JOIN public.profiles pr ON p.author_id = pr.id
LEFT JOIN public.post_metrics m ON p.id = m.post_id
@ -186,10 +185,6 @@ func (r *PostRepository) GetFeed(ctx context.Context, userID string, categorySlu
)
)
AND NOT public.has_block_between(p.author_id, CASE WHEN $4::text != '' THEN $4::text::uuid ELSE NULL END)
AND ($4::text = '' OR NOT EXISTS (
SELECT 1 FROM public.post_hides ph
WHERE ph.post_id = p.id AND ph.user_id = $4::text::uuid
))
AND ($3 = FALSE OR (COALESCE(p.video_url, '') <> '' OR (COALESCE(p.image_url, '') ILIKE '%.mp4')))
AND ($5 = '' OR c.slug = $5)
AND (
@ -225,7 +220,6 @@ func (r *PostRepository) GetFeed(ctx context.Context, userID string, categorySlu
&p.AllowChain, &p.Visibility, &p.Reactions, &p.MyReactions,
&p.IsNSFW, &p.NSFWReason,
&p.LinkPreviewURL, &p.LinkPreviewTitle, &p.LinkPreviewDescription, &p.LinkPreviewImageURL, &p.LinkPreviewSiteName,
&p.OverlayJSON,
)
if err != nil {
return nil, err
@ -364,8 +358,7 @@ func (r *PostRepository) GetPostByID(ctx context.Context, postID string, userID
p.allow_chain, p.visibility,
COALESCE(p.is_nsfw, FALSE) as is_nsfw,
COALESCE(p.nsfw_reason, '') as nsfw_reason,
p.link_preview_url, p.link_preview_title, p.link_preview_description, p.link_preview_image_url, p.link_preview_site_name,
p.overlay_json
p.link_preview_url, p.link_preview_title, p.link_preview_description, p.link_preview_image_url, p.link_preview_site_name
FROM public.posts p
JOIN public.profiles pr ON p.author_id = pr.id
LEFT JOIN public.post_metrics m ON p.id = m.post_id
@ -390,7 +383,6 @@ func (r *PostRepository) GetPostByID(ctx context.Context, postID string, userID
&p.AllowChain, &p.Visibility,
&p.IsNSFW, &p.NSFWReason,
&p.LinkPreviewURL, &p.LinkPreviewTitle, &p.LinkPreviewDescription, &p.LinkPreviewImageURL, &p.LinkPreviewSiteName,
&p.OverlayJSON,
)
if err != nil {
return nil, err
@ -501,18 +493,6 @@ func (r *PostRepository) UnlikePost(ctx context.Context, postID string, userID s
return err
}
// HidePost records a "Not Interested" signal.
// Denormalises author_id so feeds can suppress prolific-hide authors without a JOIN.
func (r *PostRepository) HidePost(ctx context.Context, postID, userID string) error {
_, err := r.pool.Exec(ctx, `
INSERT INTO public.post_hides (user_id, post_id, author_id)
SELECT $2::uuid, $1::uuid, author_id
FROM public.posts WHERE id = $1::uuid
ON CONFLICT (user_id, post_id) DO NOTHING
`, postID, userID)
return err
}
func (r *PostRepository) SavePost(ctx context.Context, postID string, userID string) error {
query := `
WITH inserted AS (

View file

@ -1,682 +0,0 @@
package services
import (
"context"
"database/sql"
"fmt"
"math"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/rs/zerolog/log"
)
type FeedAlgorithmService struct {
db *pgxpool.Pool
}
type EngagementWeight struct {
LikeWeight float64 `json:"like_weight"`
CommentWeight float64 `json:"comment_weight"`
ShareWeight float64 `json:"share_weight"`
RepostWeight float64 `json:"repost_weight"`
BoostWeight float64 `json:"boost_weight"`
AmplifyWeight float64 `json:"amplify_weight"`
ViewWeight float64 `json:"view_weight"`
TimeDecayFactor float64 `json:"time_decay_factor"`
RecencyBonus float64 `json:"recency_bonus"`
QualityWeight float64 `json:"quality_weight"`
}
type ContentQualityScore struct {
PostID string `json:"post_id"`
QualityScore float64 `json:"quality_score"`
HasMedia bool `json:"has_media"`
MediaQuality float64 `json:"media_quality"`
TextLength int `json:"text_length"`
EngagementRate float64 `json:"engagement_rate"`
OriginalityScore float64 `json:"originality_score"`
}
type FeedScore struct {
PostID string `json:"post_id"`
Score float64 `json:"score"`
EngagementScore float64 `json:"engagement_score"`
QualityScore float64 `json:"quality_score"`
RecencyScore float64 `json:"recency_score"`
NetworkScore float64 `json:"network_score"`
Personalization float64 `json:"personalization"`
LastUpdated time.Time `json:"last_updated"`
}
type UserInterestProfile struct {
UserID string `json:"user_id"`
Interests map[string]float64 `json:"interests"`
CategoryWeights map[string]float64 `json:"category_weights"`
InteractionHistory map[string]int `json:"interaction_history"`
PreferredContent []string `json:"preferred_content"`
AvoidedContent []string `json:"avoided_content"`
LastUpdated time.Time `json:"last_updated"`
}
func NewFeedAlgorithmService(db *pgxpool.Pool) *FeedAlgorithmService {
return &FeedAlgorithmService{
db: db,
}
}
// Get default engagement weights
func (s *FeedAlgorithmService) GetDefaultWeights() EngagementWeight {
return EngagementWeight{
LikeWeight: 1.0,
CommentWeight: 3.0,
ShareWeight: 5.0,
RepostWeight: 4.0,
BoostWeight: 8.0,
AmplifyWeight: 10.0,
ViewWeight: 0.1,
TimeDecayFactor: 0.95,
RecencyBonus: 1.2,
QualityWeight: 2.0,
}
}
// Calculate engagement score for a post
func (s *FeedAlgorithmService) CalculateEngagementScore(ctx context.Context, postID string, weights EngagementWeight) (float64, error) {
query := `
SELECT
COALESCE(like_count, 0) as likes,
COALESCE(comment_count, 0) as comments,
COALESCE(share_count, 0) as shares,
COALESCE(repost_count, 0) as reposts,
COALESCE(boost_count, 0) as boosts,
COALESCE(amplify_count, 0) as amplifies,
COALESCE(view_count, 0) as views,
created_at
FROM posts
WHERE id = $1
`
var likes, comments, shares, reposts, boosts, amplifies, views int
var createdAt time.Time
err := s.db.QueryRow(ctx, query, postID).Scan(
&likes, &comments, &shares, &reposts, &boosts, &amplifies, &views, &createdAt,
)
if err != nil {
return 0, fmt.Errorf("failed to get post engagement: %w", err)
}
// Calculate weighted engagement score
engagementScore := float64(likes)*weights.LikeWeight +
float64(comments)*weights.CommentWeight +
float64(shares)*weights.ShareWeight +
float64(reposts)*weights.RepostWeight +
float64(boosts)*weights.BoostWeight +
float64(amplifies)*weights.AmplifyWeight +
float64(views)*weights.ViewWeight
// Apply time decay
hoursSinceCreation := time.Since(createdAt).Hours()
timeDecay := math.Pow(weights.TimeDecayFactor, hoursSinceCreation/24.0) // Decay per day
engagementScore *= timeDecay
return engagementScore, nil
}
// Calculate content quality score
func (s *FeedAlgorithmService) CalculateContentQualityScore(ctx context.Context, postID string) (ContentQualityScore, error) {
query := `
SELECT
p.body,
p.image_url,
p.video_url,
p.created_at,
COALESCE(p.like_count, 0) as likes,
COALESCE(p.comment_count, 0) as comments,
COALESCE(p.view_count, 0) as views,
p.author_id
FROM posts p
WHERE p.id = $1
`
var body, imageURL, videoURL sql.NullString
var createdAt time.Time
var likes, comments, views int
var authorID string
err := s.db.QueryRow(ctx, query, postID).Scan(
&body, &imageURL, &videoURL, &createdAt, &likes, &comments, &views, &authorID,
)
if err != nil {
return ContentQualityScore{}, fmt.Errorf("failed to get post content: %w", err)
}
// Calculate quality metrics
hasMedia := imageURL.Valid || videoURL.Valid
textLength := 0
if body.Valid {
textLength = len(body.String)
}
// Engagement rate (engagement per view)
engagementRate := 0.0
if views > 0 {
engagementRate = float64(likes+comments) / float64(views)
}
// Media quality (simplified - could use image/video analysis)
mediaQuality := 0.0
if hasMedia {
mediaQuality = 0.8 // Base score for having media
if imageURL.Valid {
// Could integrate with image analysis service here
mediaQuality += 0.1
}
if videoURL.Valid {
// Could integrate with video analysis service here
mediaQuality += 0.1
}
}
// Text quality factors
textQuality := 0.0
if body.Valid {
textLength := len(body.String)
if textLength > 10 && textLength < 500 {
textQuality = 0.5 // Good length
} else if textLength >= 500 && textLength < 1000 {
textQuality = 0.3 // Longer but still readable
}
// Could add sentiment analysis, readability scores, etc.
}
// Originality score (simplified - could check for duplicates)
originalityScore := 0.7 // Base assumption of originality
// Calculate overall quality score
qualityScore := (mediaQuality*0.3 + textQuality*0.3 + engagementRate*0.2 + originalityScore*0.2)
return ContentQualityScore{
PostID: postID,
QualityScore: qualityScore,
HasMedia: hasMedia,
MediaQuality: mediaQuality,
TextLength: textLength,
EngagementRate: engagementRate,
OriginalityScore: originalityScore,
}, nil
}
// Calculate recency score
func (s *FeedAlgorithmService) CalculateRecencyScore(createdAt time.Time, weights EngagementWeight) float64 {
hoursSinceCreation := time.Since(createdAt).Hours()
// Recency bonus for recent content
if hoursSinceCreation < 24 {
return weights.RecencyBonus
} else if hoursSinceCreation < 72 {
return 1.0
} else if hoursSinceCreation < 168 { // 1 week
return 0.8
} else {
return 0.5
}
}
// Calculate network score based on user connections
func (s *FeedAlgorithmService) CalculateNetworkScore(ctx context.Context, postID string, viewerID string) (float64, error) {
query := `
SELECT
COUNT(DISTINCT CASE
WHEN f.following_id = $2 THEN 1
WHEN f.follower_id = $2 THEN 1
END) as connection_interactions,
COUNT(DISTINCT l.user_id) as like_connections,
COUNT(DISTINCT c.user_id) as comment_connections
FROM posts p
LEFT JOIN follows f ON (f.following_id = p.author_id OR f.follower_id = p.author_id)
LEFT JOIN post_likes l ON l.post_id = p.id AND l.user_id IN (
SELECT following_id FROM follows WHERE follower_id = $2
UNION
SELECT follower_id FROM follows WHERE following_id = $2
)
LEFT JOIN post_comments c ON c.post_id = p.id AND c.user_id IN (
SELECT following_id FROM follows WHERE follower_id = $2
UNION
SELECT follower_id FROM follows WHERE following_id = $2
)
WHERE p.id = $1
`
var connectionInteractions, likeConnections, commentConnections int
err := s.db.QueryRow(ctx, query, postID, viewerID).Scan(
&connectionInteractions, &likeConnections, &commentConnections,
)
if err != nil {
return 0, fmt.Errorf("failed to calculate network score: %w", err)
}
// Network score based on connections
networkScore := float64(connectionInteractions)*0.3 +
float64(likeConnections)*0.4 +
float64(commentConnections)*0.3
// Normalize to 0-1 range
networkScore = math.Min(networkScore/10.0, 1.0)
return networkScore, nil
}
// Calculate personalization score based on user interests
func (s *FeedAlgorithmService) CalculatePersonalizationScore(ctx context.Context, postID string, userProfile UserInterestProfile) (float64, error) {
// Get post category and content analysis
query := `
SELECT
p.category,
p.body,
p.author_id,
p.tags
FROM posts p
WHERE p.id = $1
`
var category sql.NullString
var body sql.NullString
var authorID string
var tags []string
err := s.db.QueryRow(ctx, query, postID).Scan(&category, &body, &authorID, &tags)
if err != nil {
return 0, fmt.Errorf("failed to get post for personalization: %w", err)
}
personalizationScore := 0.0
// Category matching
if category.Valid {
if weight, exists := userProfile.CategoryWeights[category.String]; exists {
personalizationScore += weight * 0.4
}
}
// Interest matching (simplified keyword matching)
if body.Valid {
text := body.String
for interest, weight := range userProfile.Interests {
// Simple keyword matching - could be enhanced with NLP
if containsKeyword(text, interest) {
personalizationScore += weight * 0.3
}
}
}
// Tag matching
for _, tag := range tags {
if weight, exists := userProfile.Interests[tag]; exists {
personalizationScore += weight * 0.2
}
}
// Author preference
if containsItem(userProfile.PreferredContent, authorID) {
personalizationScore += 0.1
}
// Avoided content penalty
if containsItem(userProfile.AvoidedContent, authorID) {
personalizationScore -= 0.5
}
// Normalize to 0-1 range
personalizationScore = math.Max(0, math.Min(personalizationScore, 1.0))
return personalizationScore, nil
}
// Calculate overall feed score for a post
func (s *FeedAlgorithmService) CalculateFeedScore(ctx context.Context, postID string, viewerID string, weights EngagementWeight, userProfile UserInterestProfile) (FeedScore, error) {
// Calculate individual components
engagementScore, err := s.CalculateEngagementScore(ctx, postID, weights)
if err != nil {
return FeedScore{}, fmt.Errorf("failed to calculate engagement score: %w", err)
}
qualityData, err := s.CalculateContentQualityScore(ctx, postID)
if err != nil {
return FeedScore{}, fmt.Errorf("failed to calculate quality score: %w", err)
}
// Get post created_at for recency
var createdAt time.Time
err = s.db.QueryRow(ctx, "SELECT created_at FROM posts WHERE id = $1", postID).Scan(&createdAt)
if err != nil {
return FeedScore{}, fmt.Errorf("failed to get post created_at: %w", err)
}
recencyScore := s.CalculateRecencyScore(createdAt, weights)
networkScore, err := s.CalculateNetworkScore(ctx, postID, viewerID)
if err != nil {
return FeedScore{}, fmt.Errorf("failed to calculate network score: %w", err)
}
personalizationScore, err := s.CalculatePersonalizationScore(ctx, postID, userProfile)
if err != nil {
return FeedScore{}, fmt.Errorf("failed to calculate personalization score: %w", err)
}
// Calculate overall score with weights
finalScore := engagementScore*0.3 +
qualityData.QualityScore*weights.QualityWeight*0.2 +
recencyScore*0.2 +
networkScore*0.15 +
personalizationScore*0.15
return FeedScore{
PostID: postID,
Score: finalScore,
EngagementScore: engagementScore,
QualityScore: qualityData.QualityScore,
RecencyScore: recencyScore,
NetworkScore: networkScore,
Personalization: personalizationScore,
LastUpdated: time.Now(),
}, nil
}
// Update feed scores for multiple posts
func (s *FeedAlgorithmService) UpdateFeedScores(ctx context.Context, postIDs []string, viewerID string) error {
weights := s.GetDefaultWeights()
// Get user profile (simplified - would normally come from user service)
userProfile := UserInterestProfile{
UserID: viewerID,
Interests: make(map[string]float64),
CategoryWeights: make(map[string]float64),
InteractionHistory: make(map[string]int),
PreferredContent: []string{},
AvoidedContent: []string{},
LastUpdated: time.Now(),
}
for _, postID := range postIDs {
score, err := s.CalculateFeedScore(ctx, postID, viewerID, weights, userProfile)
if err != nil {
log.Error().Err(err).Str("post_id", postID).Msg("failed to calculate feed score")
continue
}
// Update score in database
err = s.updatePostScore(ctx, score)
if err != nil {
log.Error().Err(err).Str("post_id", postID).Msg("failed to update post score")
}
}
return nil
}
// Update individual post score in database
func (s *FeedAlgorithmService) updatePostScore(ctx context.Context, score FeedScore) error {
query := `
INSERT INTO post_feed_scores (post_id, score, engagement_score, quality_score, recency_score, network_score, personalization, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT (post_id)
DO UPDATE SET
score = EXCLUDED.score,
engagement_score = EXCLUDED.engagement_score,
quality_score = EXCLUDED.quality_score,
recency_score = EXCLUDED.recency_score,
network_score = EXCLUDED.network_score,
personalization = EXCLUDED.personalization,
updated_at = EXCLUDED.updated_at
`
_, err := s.db.Exec(ctx, query,
score.PostID, score.Score, score.EngagementScore, score.QualityScore,
score.RecencyScore, score.NetworkScore, score.Personalization, score.LastUpdated,
)
return err
}
// GetAlgorithmicFeed returns a ranked, deduplicated, diversity-injected feed for viewerID.
//
// Scoring pipeline:
// 1. Pull scored posts from post_feed_scores; apply cooling-period multiplier based on
// when the viewer last saw each post (user_feed_impressions).
// 2. Partition the deduplicated result into 60 / 20 / 20:
// 60 % top personal scores
// 20 % random posts from categories the viewer doesn't usually see
// 20 % posts from authors the viewer doesn't follow (discovery)
// 3. Record impressions so future calls apply the cooling penalty.
func (s *FeedAlgorithmService) GetAlgorithmicFeed(ctx context.Context, viewerID string, limit int, offset int, category string) ([]string, error) {
// ── 1. Pull top personal posts (2× requested to have headroom for diversity swap) ──
personalQuery := `
SELECT pfs.post_id, pfs.score,
COALESCE(ufi.shown_at, NULL) AS last_shown,
p.category,
p.user_id AS author_id
FROM post_feed_scores pfs
JOIN posts p ON p.id = pfs.post_id
LEFT JOIN user_feed_impressions ufi
ON ufi.post_id = pfs.post_id AND ufi.user_id = $1
WHERE p.status = 'active'
AND pfs.post_id NOT IN (SELECT post_id FROM public.post_hides WHERE user_id = $1::uuid)
AND p.user_id NOT IN (
SELECT author_id FROM public.post_hides
WHERE user_id = $1::uuid GROUP BY author_id HAVING COUNT(*) >= 2
)
`
personalArgs := []interface{}{viewerID}
argIdx := 2
if category != "" {
personalQuery += fmt.Sprintf(" AND p.category = $%d", argIdx)
personalArgs = append(personalArgs, category)
argIdx++
}
personalQuery += fmt.Sprintf(`
ORDER BY pfs.score DESC, p.created_at DESC
LIMIT $%d OFFSET $%d
`, argIdx, argIdx+1)
personalArgs = append(personalArgs, limit*2, offset)
type feedRow struct {
postID string
score float64
lastShown *string // nil = never shown
category string
authorID string
}
rows, err := s.db.Query(ctx, personalQuery, personalArgs...)
if err != nil {
return nil, fmt.Errorf("failed to get algorithmic feed: %w", err)
}
defer rows.Close()
var personal []feedRow
seenCategories := map[string]int{}
for rows.Next() {
var r feedRow
if err := rows.Scan(&r.postID, &r.score, &r.lastShown, &r.category, &r.authorID); err != nil {
continue
}
// Cooling multiplier
if r.lastShown != nil {
// any non-nil means it was shown before; apply decay
r.score *= 0.2 // shown within cooling window → heavy penalty
}
seenCategories[r.category]++
personal = append(personal, r)
}
rows.Close()
// ── 2. Viewer's top 3 categories (for diversity contrast) ──
topCats := topN(seenCategories, 3)
topCatSet := map[string]bool{}
for _, c := range topCats {
topCatSet[c] = true
}
// ── 3. Split quotas ──
totalSlots := limit
if offset > 0 {
// On paginated pages skip diversity injection (too complex, just serve personal)
var ids []string
for i, r := range personal {
if i >= totalSlots {
break
}
ids = append(ids, r.postID)
}
s.recordImpressions(ctx, viewerID, ids)
return ids, nil
}
personalSlots := (totalSlots * 60) / 100
crossCatSlots := (totalSlots * 20) / 100
discoverySlots := totalSlots - personalSlots - crossCatSlots
var result []string
seen := map[string]bool{}
for _, r := range personal {
if len(result) >= personalSlots {
break
}
if !seen[r.postID] {
result = append(result, r.postID)
seen[r.postID] = true
}
}
// ── 4. Cross-category posts (20 %) ──
if crossCatSlots > 0 && len(topCats) > 0 {
placeholders := ""
catArgs := []interface{}{viewerID, crossCatSlots}
for i, c := range topCats {
if i > 0 {
placeholders += ","
}
placeholders += fmt.Sprintf("$%d", len(catArgs)+1)
catArgs = append(catArgs, c)
}
crossQuery := fmt.Sprintf(`
SELECT p.id FROM posts p
JOIN post_feed_scores pfs ON pfs.post_id = p.id
WHERE p.status = 'active'
AND p.category NOT IN (%s)
AND p.id NOT IN (SELECT post_id FROM public.post_hides WHERE user_id = $1)
AND p.user_id NOT IN (
SELECT author_id FROM public.post_hides
WHERE user_id = $1 GROUP BY author_id HAVING COUNT(*) >= 2
)
ORDER BY random()
LIMIT $2
`, placeholders)
crossRows, _ := s.db.Query(ctx, crossQuery, catArgs...)
if crossRows != nil {
for crossRows.Next() {
var id string
if crossRows.Scan(&id) == nil && !seen[id] {
result = append(result, id)
seen[id] = true
}
}
crossRows.Close()
}
}
// ── 5. Discovery posts from non-followed authors (20 %) ──
if discoverySlots > 0 {
discQuery := `
SELECT p.id FROM posts p
JOIN post_feed_scores pfs ON pfs.post_id = p.id
WHERE p.status = 'active'
AND p.user_id != $1
AND p.user_id NOT IN (
SELECT following_id FROM follows WHERE follower_id = $1
)
ORDER BY random()
LIMIT $2
`
discRows, _ := s.db.Query(ctx, discQuery, viewerID, discoverySlots)
if discRows != nil {
for discRows.Next() {
var id string
if discRows.Scan(&id) == nil && !seen[id] {
result = append(result, id)
seen[id] = true
}
}
discRows.Close()
}
}
// ── 6. Record impressions ──
s.recordImpressions(ctx, viewerID, result)
return result, nil
}
// recordImpressions upserts impression rows so cooling periods take effect on future loads.
func (s *FeedAlgorithmService) recordImpressions(ctx context.Context, userID string, postIDs []string) {
if len(postIDs) == 0 {
return
}
for _, pid := range postIDs {
s.db.Exec(ctx,
`INSERT INTO user_feed_impressions (user_id, post_id, shown_at)
VALUES ($1, $2, now())
ON CONFLICT (user_id, post_id) DO UPDATE SET shown_at = now()`,
userID, pid,
)
}
}
// topN returns up to n keys with the highest counts from a frequency map.
func topN(m map[string]int, n int) []string {
type kv struct {
k string
v int
}
var pairs []kv
for k, v := range m {
pairs = append(pairs, kv{k, v})
}
// simple selection sort (n is always ≤ 3)
for i := 0; i < len(pairs)-1; i++ {
max := i
for j := i + 1; j < len(pairs); j++ {
if pairs[j].v > pairs[max].v {
max = j
}
}
pairs[i], pairs[max] = pairs[max], pairs[i]
}
result := make([]string, 0, n)
for i := 0; i < n && i < len(pairs); i++ {
result = append(result, pairs[i].k)
}
return result
}
// Helper functions
func containsKeyword(text, keyword string) bool {
return len(text) > 0 && len(keyword) > 0 // Simplified - could use regex or NLP
}
func containsItem(slice []string, item string) bool {
for _, s := range slice {
if s == item {
return true
}
}
return false
}

View file

@ -0,0 +1,96 @@
package services
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"time"
)
type TurnstileService struct {
secretKey string
client *http.Client
}
type TurnstileResponse struct {
Success bool `json:"success"`
ErrorCodes []string `json:"error-codes,omitempty"`
ChallengeTS string `json:"challenge_ts,omitempty"`
Hostname string `json:"hostname,omitempty"`
Action string `json:"action,omitempty"`
Cdata string `json:"cdata,omitempty"`
}
func NewTurnstileService(secretKey string) *TurnstileService {
return &TurnstileService{
secretKey: secretKey,
client: &http.Client{
Timeout: 10 * time.Second,
},
}
}
// VerifyToken validates a Turnstile token with Cloudflare
func (s *TurnstileService) VerifyToken(token, remoteIP string) (*TurnstileResponse, error) {
if s.secretKey == "" {
// If no secret key is configured, skip verification (for development)
return &TurnstileResponse{Success: true}, nil
}
// Prepare the request data (properly form-encoded)
form := url.Values{}
form.Set("secret", s.secretKey)
form.Set("response", token)
if remoteIP != "" {
form.Set("remoteip", remoteIP)
}
// Make the request to Cloudflare
resp, err := s.client.Post(
"https://challenges.cloudflare.com/turnstile/v0/siteverify",
"application/x-www-form-urlencoded",
bytes.NewBufferString(form.Encode()),
)
if err != nil {
return nil, fmt.Errorf("failed to verify turnstile token: %w", err)
}
defer resp.Body.Close()
// Read the response
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read turnstile response: %w", err)
}
// Parse the response
var result TurnstileResponse
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("failed to parse turnstile response: %w", err)
}
return &result, nil
}
// GetErrorMessage returns a user-friendly error message for error codes
func (s *TurnstileService) GetErrorMessage(errorCodes []string) string {
errorMessages := map[string]string{
"missing-input-secret": "Server configuration error",
"invalid-input-secret": "Server configuration error",
"missing-input-response": "Please complete the security check",
"invalid-input-response": "Security check failed, please try again",
"bad-request": "Invalid request format",
"timeout-or-duplicate": "Security check expired, please try again",
"internal-error": "Verification service unavailable",
}
for _, code := range errorCodes {
if msg, exists := errorMessages[code]; exists {
return msg
}
}
return "Security verification failed"
}

View file

@ -1,189 +0,0 @@
package services
import (
"bytes"
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/google/uuid"
)
// VideoProcessor handles video frame extraction and analysis
type VideoProcessor struct {
ffmpegPath string
tempDir string
s3Client *s3.Client
videoBucket string
vidDomain string
}
// NewVideoProcessor creates a new video processor service
func NewVideoProcessor(s3Client *s3.Client, videoBucket, vidDomain string) *VideoProcessor {
ffmpegPath, _ := exec.LookPath("ffmpeg")
return &VideoProcessor{
ffmpegPath: ffmpegPath,
tempDir: "/tmp",
s3Client: s3Client,
videoBucket: videoBucket,
vidDomain: vidDomain,
}
}
// ExtractFrames extracts key frames from a video URL for moderation analysis.
// Frames are uploaded to R2 and their signed URLs are returned.
func (vp *VideoProcessor) ExtractFrames(ctx context.Context, videoURL string, frameCount int) ([]string, error) {
if vp.ffmpegPath == "" {
return nil, fmt.Errorf("ffmpeg not found on system")
}
// Generate unique temp output pattern (ffmpeg uses %03d for frame numbering)
baseName := fmt.Sprintf("vframe_%s_%%03d.jpg", uuid.New().String())
tempPattern := filepath.Join(vp.tempDir, baseName)
if frameCount < 1 {
frameCount = 1
}
// Extract up to frameCount key frames distributed across the video
cmd := exec.CommandContext(ctx, vp.ffmpegPath,
"-i", videoURL,
"-vf", fmt.Sprintf("select=not(mod(n\\,%d)),scale=640:480", frameCount),
"-frames:v", fmt.Sprintf("%d", frameCount),
"-y",
tempPattern,
)
output, err := cmd.CombinedOutput()
if err != nil {
return nil, fmt.Errorf("ffmpeg extraction failed: %v, output: %s", err, string(output))
}
// Collect generated frame files
glob := strings.Replace(tempPattern, "%03d", "*", 1)
frameFiles, err := filepath.Glob(glob)
if err != nil || len(frameFiles) == 0 {
return nil, fmt.Errorf("no frames extracted from video")
}
// Upload each frame to R2 and collect signed URLs
var signedURLs []string
for _, framePath := range frameFiles {
url, uploadErr := vp.uploadFrame(ctx, framePath)
os.Remove(framePath) // always clean up temp file
if uploadErr != nil {
continue // best-effort: skip failed frames
}
signedURLs = append(signedURLs, url)
}
if len(signedURLs) == 0 {
return nil, fmt.Errorf("failed to upload any extracted frames to R2")
}
return signedURLs, nil
}
// uploadFrame uploads a local frame file to R2 and returns its signed URL.
func (vp *VideoProcessor) uploadFrame(ctx context.Context, localPath string) (string, error) {
if vp.s3Client == nil || vp.videoBucket == "" {
return "", fmt.Errorf("R2 storage not configured")
}
data, err := os.ReadFile(localPath)
if err != nil {
return "", fmt.Errorf("read frame file: %w", err)
}
r2Key := fmt.Sprintf("videos/frames/%s.jpg", uuid.New().String())
_, err = vp.s3Client.PutObject(ctx, &s3.PutObjectInput{
Bucket: aws.String(vp.videoBucket),
Key: aws.String(r2Key),
Body: bytes.NewReader(data),
ContentType: aws.String("image/jpeg"),
})
if err != nil {
return "", fmt.Errorf("upload frame to R2: %w", err)
}
// Build a signed URL using the same HMAC pattern as AssetService
base := vp.vidDomain
if base == "" {
return r2Key, nil
}
if !strings.HasPrefix(base, "http") {
base = "https://" + base
}
return fmt.Sprintf("%s/%s", base, r2Key), nil
}
// GetVideoDuration returns the duration of a video in seconds
func (vp *VideoProcessor) GetVideoDuration(ctx context.Context, videoURL string) (float64, error) {
if vp.ffmpegPath == "" {
return 0, fmt.Errorf("ffmpeg not found on system")
}
cmd := exec.CommandContext(ctx, vp.ffmpegPath,
"-i", videoURL,
"-f", "null",
"-",
)
output, err := cmd.CombinedOutput()
if err != nil {
return 0, fmt.Errorf("failed to get video duration: %v", err)
}
// Parse duration from ffmpeg output
outputStr := string(output)
durationStr := ""
// Look for "Duration: HH:MM:SS.ms" pattern
lines := strings.Split(outputStr, "\n")
for _, line := range lines {
if strings.Contains(line, "Duration:") {
parts := strings.Split(line, "Duration:")
if len(parts) > 1 {
durationStr = strings.TrimSpace(parts[1])
// Remove everything after the first comma
if commaIdx := strings.Index(durationStr, ","); commaIdx != -1 {
durationStr = durationStr[:commaIdx]
}
break
}
}
}
if durationStr == "" {
return 0, fmt.Errorf("could not parse duration from ffmpeg output")
}
// Parse HH:MM:SS.ms format
var hours, minutes, seconds float64
_, err = fmt.Sscanf(durationStr, "%f:%f:%f", &hours, &minutes, &seconds)
if err != nil {
return 0, fmt.Errorf("failed to parse duration format: %v", err)
}
totalSeconds := hours*3600 + minutes*60 + seconds
return totalSeconds, nil
}
// IsVideoURL checks if a URL points to a video file
func IsVideoURL(url string) bool {
videoExtensions := []string{".mp4", ".avi", ".mov", ".mkv", ".webm", ".flv", ".wmv", ".m4v"}
lowerURL := strings.ToLower(url)
for _, ext := range videoExtensions {
if strings.HasSuffix(lowerURL, ext) {
return true
}
}
return false
}

View file

@ -1,508 +0,0 @@
package testing
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
)
// IntegrationTestSuite provides comprehensive testing for the Sojorn platform
type IntegrationTestSuite struct {
suite.Suite
db *pgxpool.Pool
router *gin.Engine
server *httptest.Server
testUser *TestUser
testGroup *TestGroup
testPost *TestPost
cleanup []func()
}
// TestUser represents a test user
type TestUser struct {
ID string `json:"id"`
Email string `json:"email"`
Handle string `json:"handle"`
Token string `json:"token"`
Password string `json:"password"`
}
// TestGroup represents a test group
type TestGroup struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Category string `json:"category"`
IsPrivate bool `json:"is_private"`
}
// TestPost represents a test post
type TestPost struct {
ID string `json:"id"`
Body string `json:"body"`
AuthorID string `json:"author_id"`
ImageURL string `json:"image_url,omitempty"`
VideoURL string `json:"video_url,omitempty"`
Visibility string `json:"visibility"`
}
// TestConfig holds test configuration
type TestConfig struct {
DatabaseURL string
BaseURL string
TestTimeout time.Duration
}
// SetupSuite initializes the test suite
func (suite *IntegrationTestSuite) SetupSuite() {
config := suite.getTestConfig()
// Initialize database
db, err := pgxpool.New(context.Background(), config.DatabaseURL)
require.NoError(suite.T(), err)
suite.db = db
// Initialize router
suite.router = gin.New()
suite.setupRoutes()
// Start test server
suite.server = httptest.NewServer(suite.router)
// Create test data
suite.createTestData()
}
// TearDownSuite cleans up after tests
func (suite *IntegrationTestSuite) TearDownSuite() {
// Run cleanup functions
for _, cleanup := range suite.cleanup {
cleanup()
}
// Close database connection
if suite.db != nil {
suite.db.Close()
}
// Close test server
if suite.server != nil {
suite.server.Close()
}
}
// getTestConfig loads test configuration
func (suite *IntegrationTestSuite) getTestConfig() TestConfig {
return TestConfig{
DatabaseURL: os.Getenv("TEST_DATABASE_URL"),
BaseURL: "http://localhost:8080",
TestTimeout: 30 * time.Second,
}
}
// setupRoutes configures test routes
func (suite *IntegrationTestSuite) setupRoutes() {
// This would include all your API routes
// For now, we'll add basic health check
suite.router.GET("/health", func(c *gin.Context) {
c.JSON(200, gin.H{"status": "healthy"})
})
// Add auth routes
suite.router.POST("/auth/register", suite.handleRegister)
suite.router.POST("/auth/login", suite.handleLogin)
// Add post routes
suite.router.GET("/posts", suite.handleGetPosts)
suite.router.POST("/posts", suite.handleCreatePost)
// Add group routes
suite.router.GET("/groups", suite.handleGetGroups)
suite.router.POST("/groups", suite.handleCreateGroup)
}
// createTestData sets up test data
func (suite *IntegrationTestSuite) createTestData() {
// Create test user
suite.testUser = &TestUser{
Email: "test@example.com",
Handle: "testuser",
Password: "testpassword123",
}
userResp := suite.makeRequest("POST", "/auth/register", suite.testUser)
require.Equal(suite.T(), 200, userResp.StatusCode)
var userResult struct {
User TestUser `json:"user"`
Token string `json:"token"`
}
json.NewDecoder(userResp.Body).Decode(&userResult)
suite.testUser = &userResult.User
suite.testUser.Token = userResult.Token
// Create test group
suite.testGroup = &TestGroup{
Name: "Test Group",
Description: "A group for testing",
Category: "general",
IsPrivate: false,
}
groupResp := suite.makeAuthenticatedRequest("POST", "/groups", suite.testGroup)
require.Equal(suite.T(), 200, groupResp.StatusCode)
json.NewDecoder(groupResp.Body).Decode(&suite.testGroup)
// Create test post
suite.testPost = &TestPost{
Body: "This is a test post",
AuthorID: suite.testUser.ID,
Visibility: "public",
}
postResp := suite.makeAuthenticatedRequest("POST", "/posts", suite.testPost)
require.Equal(suite.T(), 200, postResp.StatusCode)
json.NewDecoder(postResp.Body).Decode(&suite.testPost)
}
// makeRequest makes an HTTP request
func (suite *IntegrationTestSuite) makeRequest(method, path string, body interface{}) *http.Response {
var reqBody *bytes.Buffer
if body != nil {
jsonBody, _ := json.Marshal(body)
reqBody = bytes.NewBuffer(jsonBody)
} else {
reqBody = bytes.NewBuffer(nil)
}
req, _ := http.NewRequest(method, suite.server.URL+path, reqBody)
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 10 * time.Second}
resp, _ := client.Do(req)
return resp
}
// makeAuthenticatedRequest makes an authenticated HTTP request
func (suite *IntegrationTestSuite) makeAuthenticatedRequest(method, path string, body interface{}) *http.Response {
var reqBody *bytes.Buffer
if body != nil {
jsonBody, _ := json.Marshal(body)
reqBody = bytes.NewBuffer(jsonBody)
} else {
reqBody = bytes.NewBuffer(nil)
}
req, _ := http.NewRequest(method, suite.server.URL+path, reqBody)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+suite.testUser.Token)
client := &http.Client{Timeout: 10 * time.Second}
resp, _ := client.Do(req)
return resp
}
// Test Authentication Flow
func (suite *IntegrationTestSuite) TestAuthenticationFlow() {
// Test user registration
newUser := TestUser{
Email: "newuser@example.com",
Handle: "newuser",
Password: "newpassword123",
}
resp := suite.makeRequest("POST", "/auth/register", newUser)
assert.Equal(suite.T(), 200, resp.StatusCode)
var registerResult struct {
User TestUser `json:"user"`
Token string `json:"token"`
}
json.NewDecoder(resp.Body).Decode(&registerResult)
assert.NotEmpty(suite.T(), registerResult.Token)
// Test user login
loginReq := map[string]string{
"email": newUser.Email,
"password": newUser.Password,
}
resp = suite.makeRequest("POST", "/auth/login", loginReq)
assert.Equal(suite.T(), 200, resp.StatusCode)
var loginResult struct {
User TestUser `json:"user"`
Token string `json:"token"`
}
json.NewDecoder(resp.Body).Decode(&loginResult)
assert.NotEmpty(suite.T(), loginResult.Token)
}
// Test Post Creation and Retrieval
func (suite *IntegrationTestSuite) TestPostOperations() {
// Test creating a post
newPost := TestPost{
Body: "This is a new test post",
AuthorID: suite.testUser.ID,
Visibility: "public",
}
resp := suite.makeAuthenticatedRequest("POST", "/posts", newPost)
assert.Equal(suite.T(), 200, resp.StatusCode)
var createdPost TestPost
json.NewDecoder(resp.Body).Decode(&createdPost)
assert.NotEmpty(suite.T(), createdPost.ID)
// Test retrieving posts
resp = suite.makeAuthenticatedRequest("GET", "/posts", nil)
assert.Equal(suite.T(), 200, resp.StatusCode)
var posts []TestPost
json.NewDecoder(resp.Body).Decode(&posts)
assert.Greater(suite.T(), len(posts), 0)
}
// Test Group Operations
func (suite *IntegrationTestSuite) TestGroupOperations() {
// Test creating a group
newGroup := TestGroup{
Name: "New Test Group",
Description: "Another test group",
Category: "hobby",
IsPrivate: false,
}
resp := suite.makeAuthenticatedRequest("POST", "/groups", newGroup)
assert.Equal(suite.T(), 200, resp.StatusCode)
var createdGroup TestGroup
json.NewDecoder(resp.Body).Decode(&createdGroup)
assert.NotEmpty(suite.T(), createdGroup.ID)
// Test retrieving groups
resp = suite.makeAuthenticatedRequest("GET", "/groups", nil)
assert.Equal(suite.T(), 200, resp.StatusCode)
var groups []TestGroup
json.NewDecoder(resp.Body).Decode(&groups)
assert.Greater(suite.T(), len(groups), 0)
}
// Test Feed Algorithm
func (suite *IntegrationTestSuite) TestFeedAlgorithm() {
// Create multiple posts with different engagement
posts := []TestPost{
{Body: "Popular post 1", AuthorID: suite.testUser.ID, Visibility: "public"},
{Body: "Popular post 2", AuthorID: suite.testUser.ID, Visibility: "public"},
{Body: "Regular post", AuthorID: suite.testUser.ID, Visibility: "public"},
}
for _, post := range posts {
resp := suite.makeAuthenticatedRequest("POST", "/posts", post)
assert.Equal(suite.T(), 200, resp.StatusCode)
}
// Test algorithmic feed
resp := suite.makeAuthenticatedRequest("GET", "/feed?algorithm=true", nil)
assert.Equal(suite.T(), 200, resp.StatusCode)
var feedPosts []TestPost
json.NewDecoder(resp.Body).Decode(&feedPosts)
assert.Greater(suite.T(), len(feedPosts), 0)
}
// Test E2EE Chat
func (suite *IntegrationTestSuite) TestE2EEChat() {
// Test device registration
deviceData := map[string]interface{}{
"name": "Test Device",
"type": "mobile",
"public_key": "test-public-key",
}
resp := suite.makeAuthenticatedRequest("POST", "/e2ee/register-device", deviceData)
assert.Equal(suite.T(), 200, resp.StatusCode)
// Test message encryption
messageData := map[string]interface{}{
"recipient_id": suite.testUser.ID,
"message": "Encrypted test message",
"encrypted": true,
}
resp = suite.makeAuthenticatedRequest("POST", "/e2ee/send-message", messageData)
assert.Equal(suite.T(), 200, resp.StatusCode)
}
// Test AI Moderation
func (suite *IntegrationTestSuite) TestAIModeration() {
// Test content moderation
contentData := map[string]interface{}{
"content": "This is safe content",
"type": "text",
}
resp := suite.makeAuthenticatedRequest("POST", "/moderation/analyze", contentData)
assert.Equal(suite.T(), 200, resp.StatusCode)
var moderationResult struct {
IsSafe bool `json:"is_safe"`
Score float64 `json:"score"`
}
json.NewDecoder(resp.Body).Decode(&moderationResult)
assert.True(suite.T(), moderationResult.IsSafe)
}
// Test Video Processing
func (suite *IntegrationTestSuite) TestVideoProcessing() {
// Test video upload and processing
videoData := map[string]interface{}{
"title": "Test Video",
"description": "A test video for processing",
"duration": 30,
}
resp := suite.makeAuthenticatedRequest("POST", "/videos/upload", videoData)
assert.Equal(suite.T(), 200, resp.StatusCode)
// Test video processing status
resp = suite.makeAuthenticatedRequest("GET", "/videos/processing-status", nil)
assert.Equal(suite.T(), 200, resp.StatusCode)
}
// Test Performance
func (suite *IntegrationTestSuite) TestPerformance() {
// Test API response times
start := time.Now()
resp := suite.makeAuthenticatedRequest("GET", "/posts", nil)
duration := time.Since(start)
assert.Equal(suite.T(), 200, resp.StatusCode)
assert.Less(suite.T(), duration, 1*time.Second, "API response time should be under 1 second")
// Test concurrent requests
concurrentRequests := 10
done := make(chan bool, concurrentRequests)
for i := 0; i < concurrentRequests; i++ {
go func() {
resp := suite.makeAuthenticatedRequest("GET", "/posts", nil)
assert.Equal(suite.T(), 200, resp.StatusCode)
done <- true
}()
}
// Wait for all requests to complete
for i := 0; i < concurrentRequests; i++ {
<-done
}
}
// Test Security
func (suite *IntegrationTestSuite) TestSecurity() {
// Test unauthorized access
resp := suite.makeRequest("GET", "/posts", nil)
assert.Equal(suite.T(), 401, resp.StatusCode)
// Test invalid token
resp = suite.makeRequestWithAuth("GET", "/posts", nil, "invalid-token")
assert.Equal(suite.T(), 401, resp.StatusCode)
// Test SQL injection protection
maliciousInput := "'; DROP TABLE users; --"
resp = suite.makeAuthenticatedRequest("GET", "/posts?search="+maliciousInput, nil)
assert.Equal(suite.T(), 200, resp.StatusCode) // Should not crash
}
// makeRequestWithAuth makes a request with custom auth token
func (suite *IntegrationTestSuite) makeRequestWithAuth(method, path string, body interface{}, token string) *http.Response {
var reqBody *bytes.Buffer
if body != nil {
jsonBody, _ := json.Marshal(body)
reqBody = bytes.NewBuffer(jsonBody)
} else {
reqBody = bytes.NewBuffer(nil)
}
req, _ := http.NewRequest(method, suite.server.URL+path, reqBody)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
client := &http.Client{Timeout: 10 * time.Second}
resp, _ := client.Do(req)
return resp
}
// Mock handlers for testing
func (suite *IntegrationTestSuite) handleRegister(c *gin.Context) {
var user TestUser
c.ShouldBindJSON(&user)
user.ID = "test-user-id"
c.JSON(200, gin.H{"user": user, "token": "test-token"})
}
func (suite *IntegrationTestSuite) handleLogin(c *gin.Context) {
var loginReq map[string]string
c.ShouldBindJSON(&loginReq)
user := TestUser{
ID: "test-user-id",
Email: loginReq["email"],
Handle: "testuser",
}
c.JSON(200, gin.H{"user": user, "token": "test-token"})
}
func (suite *IntegrationTestSuite) handleGetPosts(c *gin.Context) {
posts := []TestPost{
{ID: "1", Body: "Test post 1", AuthorID: "test-user-id"},
{ID: "2", Body: "Test post 2", AuthorID: "test-user-id"},
}
c.JSON(200, posts)
}
func (suite *IntegrationTestSuite) handleCreatePost(c *gin.Context) {
var post TestPost
c.ShouldBindJSON(&post)
post.ID = "new-post-id"
c.JSON(200, post)
}
func (suite *IntegrationTestSuite) handleGetGroups(c *gin.Context) {
groups := []TestGroup{
{ID: "1", Name: "Test Group 1", Category: "general"},
{ID: "2", Name: "Test Group 2", Category: "hobby"},
}
c.JSON(200, groups)
}
func (suite *IntegrationTestSuite) handleCreateGroup(c *gin.Context) {
var group TestGroup
c.ShouldBindJSON(&group)
group.ID = "new-group-id"
c.JSON(200, group)
}
// RunIntegrationTests runs the complete integration test suite
func RunIntegrationTests(t *testing.T) {
suite.Run(t, new(IntegrationTestSuite))
}

View file

@ -1,75 +0,0 @@
-- Migration: Add reposts, profile_layouts, and post_feed_scores tables
-- Also adds engagement count columns to posts for feed algorithm
-- ─── Engagement columns on posts ──────────────────────────────────────────────
ALTER TABLE public.posts
ADD COLUMN IF NOT EXISTS like_count INTEGER NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS comment_count INTEGER NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS share_count INTEGER NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS repost_count INTEGER NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS boost_count INTEGER NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS amplify_count INTEGER NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS view_count INTEGER NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS video_url TEXT;
-- Backfill existing like/comment/view counts from post_metrics
UPDATE public.posts p
SET
like_count = COALESCE(m.like_count, 0),
comment_count = COALESCE(m.comment_count, 0),
view_count = COALESCE(m.view_count, 0)
FROM public.post_metrics m
WHERE p.id = m.post_id;
-- ─── Reposts ──────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS public.reposts (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
original_post_id UUID NOT NULL REFERENCES public.posts(id) ON DELETE CASCADE,
author_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
type TEXT NOT NULL CHECK (type IN ('standard', 'quote', 'boost', 'amplify')),
comment TEXT,
metadata JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- One repost per type per user per post
CREATE UNIQUE INDEX IF NOT EXISTS idx_reposts_unique
ON public.reposts (original_post_id, author_id, type);
CREATE INDEX IF NOT EXISTS idx_reposts_original_post_id ON public.reposts (original_post_id);
CREATE INDEX IF NOT EXISTS idx_reposts_author_id ON public.reposts (author_id);
CREATE INDEX IF NOT EXISTS idx_reposts_created_at ON public.reposts (created_at DESC);
-- ─── Repost reports ───────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS public.repost_reports (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
repost_id UUID NOT NULL REFERENCES public.reposts(id) ON DELETE CASCADE,
reporter_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
reason TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (repost_id, reporter_id)
);
-- ─── Profile widget layouts ───────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS public.profile_layouts (
user_id UUID PRIMARY KEY REFERENCES public.profiles(id) ON DELETE CASCADE,
widgets JSONB NOT NULL DEFAULT '[]',
theme VARCHAR(50) NOT NULL DEFAULT 'default',
accent_color VARCHAR(20),
banner_image_url TEXT,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- ─── Post feed scores (feed algorithm) ───────────────────────────────────────
CREATE TABLE IF NOT EXISTS public.post_feed_scores (
post_id UUID PRIMARY KEY REFERENCES public.posts(id) ON DELETE CASCADE,
score DOUBLE PRECISION NOT NULL DEFAULT 0,
engagement_score DOUBLE PRECISION NOT NULL DEFAULT 0,
quality_score DOUBLE PRECISION NOT NULL DEFAULT 0,
recency_score DOUBLE PRECISION NOT NULL DEFAULT 0,
network_score DOUBLE PRECISION NOT NULL DEFAULT 0,
personalization DOUBLE PRECISION NOT NULL DEFAULT 0,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_post_feed_scores_score ON public.post_feed_scores (score DESC);

View file

@ -1,22 +0,0 @@
-- Capsule report system: members voluntarily submit decrypted evidence when reporting
-- encrypted entries. The server stores the plaintext sample provided by the reporter.
CREATE TABLE IF NOT EXISTS capsule_reports (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
reporter_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
capsule_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
entry_id UUID NOT NULL REFERENCES capsule_entries(id) ON DELETE CASCADE,
decrypted_sample TEXT, -- plaintext voluntarily provided by the reporter as evidence
reason TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'reviewed', 'actioned', 'dismissed')),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Prevent duplicate reports from the same user for the same entry
CREATE UNIQUE INDEX IF NOT EXISTS uq_capsule_reports_reporter_entry
ON capsule_reports (reporter_id, entry_id);
CREATE INDEX IF NOT EXISTS idx_capsule_reports_status ON capsule_reports (status);
CREATE INDEX IF NOT EXISTS idx_capsule_reports_capsule ON capsule_reports (capsule_id);

View file

@ -1,23 +0,0 @@
-- Feed cooling period: track what each user has seen
CREATE TABLE IF NOT EXISTS user_feed_impressions (
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
post_id uuid NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
shown_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, post_id)
);
CREATE INDEX IF NOT EXISTS idx_feed_impressions_user_time ON user_feed_impressions(user_id, shown_at);
-- E2EE group key management
ALTER TABLE groups ADD COLUMN IF NOT EXISTS key_rotation_needed bool NOT NULL DEFAULT false;
ALTER TABLE groups ADD COLUMN IF NOT EXISTS key_version int NOT NULL DEFAULT 1;
CREATE TABLE IF NOT EXISTS group_member_keys (
group_id uuid NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
key_version int NOT NULL DEFAULT 1,
encrypted_key text NOT NULL,
device_key_id text,
updated_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (group_id, user_id, key_version)
);
CREATE INDEX IF NOT EXISTS idx_group_member_keys_group ON group_member_keys(group_id, key_version);

View file

@ -1,16 +0,0 @@
-- Waitlist table for managing early-access signups
CREATE TABLE IF NOT EXISTS waitlist (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
email text NOT NULL UNIQUE,
name text,
referral_code text,
invited_by text, -- email or user handle of referrer
status text NOT NULL DEFAULT 'pending', -- pending, approved, rejected, invited
notes text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_waitlist_status ON waitlist(status);
CREATE INDEX IF NOT EXISTS idx_waitlist_created ON waitlist(created_at DESC);

View file

@ -1,12 +0,0 @@
-- Alter existing waitlist table to add missing columns
ALTER TABLE waitlist
ADD COLUMN IF NOT EXISTS referral_code text,
ADD COLUMN IF NOT EXISTS invited_by text,
ADD COLUMN IF NOT EXISTS status text NOT NULL DEFAULT 'pending',
ADD COLUMN IF NOT EXISTS notes text,
ADD COLUMN IF NOT EXISTS name text,
ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now();
CREATE INDEX IF NOT EXISTS idx_waitlist_status ON waitlist(status);
CREATE INDEX IF NOT EXISTS idx_waitlist_created ON waitlist(created_at DESC);

View file

@ -1,160 +0,0 @@
package main
import (
"database/sql"
"fmt"
"log"
"os"
_ "github.com/lib/pq"
)
func main() {
dbURL := os.Getenv("DATABASE_URL")
if dbURL == "" {
log.Fatal("DATABASE_URL environment variable is not set")
}
db, err := sql.Open("postgres", dbURL)
if err != nil {
log.Fatal(err)
}
defer db.Close()
// Read and execute the seed file
seedSQL := `
-- Comprehensive Groups Seeding
-- Seed 15 demo groups across all categories with realistic data
INSERT INTO groups (
name,
description,
category,
is_private,
avatar_url,
banner_url,
created_by,
member_count,
post_count
) VALUES
-- General Category
('Tech Innovators', 'Discussing the latest in technology, AI, and digital innovation. Share your projects and get feedback from fellow tech enthusiasts.', 'general', false, 'https://media.sojorn.net/tech-avatar.jpg', 'https://media.sojorn.net/tech-banner.jpg', 1, 245, 892),
('Creative Minds', 'A space for artists, designers, and creative professionals to share work, get inspiration, and collaborate on projects.', 'general', false, 'https://media.sojorn.net/creative-avatar.jpg', 'https://media.sojorn.net/creative-banner.jpg', 2, 189, 567),
-- Hobby Category
('Photography Club', 'Share your best shots, get feedback, learn techniques, and discuss gear. All skill levels welcome!', 'hobby', false, 'https://media.sojorn.net/photo-avatar.jpg', 'https://media.sojorn.net/photo-banner.jpg', 3, 156, 423),
('Garden Enthusiasts', 'From balcony gardens to small farms. Share tips, show off your plants, and connect with fellow gardeners.', 'hobby', true, 'https://media.sojorn.net/garden-avatar.jpg', 'https://media.sojorn.net/garden-banner.jpg', 4, 78, 234),
('Home Cooking Masters', 'Share recipes, cooking techniques, and kitchen adventures. From beginners to gourmet chefs.', 'hobby', false, 'https://media.sojorn.net/cooking-avatar.jpg', 'https://media.sojorn.net/cooking-banner.jpg', 5, 312, 891),
-- Sports Category
('Runners United', 'Training tips, race experiences, and running routes. Connect with runners of all levels in your area.', 'sports', false, 'https://media.sojorn.net/running-avatar.jpg', 'https://media.sojorn.net/running-banner.jpg', 6, 423, 1256),
('Yoga & Wellness', 'Daily practice sharing, meditation techniques, and wellness discussions. All levels welcome.', 'sports', false, 'https://media.sojorn.net/yoga-avatar.jpg', 'https://media.sojorn.net/yoga-banner.jpg', 7, 267, 789),
('Cycling Community', 'Road cycling, mountain biking, and urban cycling. Share routes, gear reviews, and group ride info.', 'sports', true, 'https://media.sojorn.net/cycling-avatar.jpg', 'https://media.sojorn.net/cycling-banner.jpg', 8, 198, 567),
-- Professional Category
('Startup Founders', 'Connect with fellow entrepreneurs, share experiences, and discuss the challenges of building companies.', 'professional', true, 'https://media.sojorn.net/startup-avatar.jpg', 'https://media.sojorn.net/startup-banner.jpg', 9, 134, 445),
('Remote Work Professionals', 'Tips, tools, and discussions about working remotely. Share your home office setup and productivity hacks.', 'professional', false, 'https://media.sojorn.net/remote-avatar.jpg', 'https://media.sojorn.net/remote-banner.jpg', 10, 523, 1567),
('Software Developers', 'Code reviews, tech discussions, career advice, and programming language debates. All languages welcome.', 'professional', false, 'https://media.sojorn.net/dev-avatar.jpg', 'https://media.sojorn.net/dev-banner.jpg', 11, 678, 2341),
-- Local Business Category
('Local Coffee Shops', 'Supporting local cafés and coffee culture. Share your favorite spots, reviews, and coffee experiences.', 'local_business', false, 'https://media.sojorn.net/coffee-avatar.jpg', 'https://media.sojorn.net/coffee-banner.jpg', 12, 89, 267),
('Farmers Market Fans', 'Celebrating local farmers markets, farm-to-table eating, and supporting local agriculture.', 'local_business', false, 'https://media.sojorn.net/market-avatar.jpg', 'https://media.sojorn.net/market-banner.jpg', 13, 156, 445),
-- Support Category
('Mental Health Support', 'A safe space to discuss mental health, share coping strategies, and find support. Confidential and respectful.', 'support', true, 'https://media.sojorn.net/mental-avatar.jpg', 'https://media.sojorn.net/mental-banner.jpg', 14, 234, 678),
('Parenting Community', 'Share parenting experiences, get advice, and connect with other parents. All parenting stages welcome.', 'support', false, 'https://media.sojorn.net/parenting-avatar.jpg', 'https://media.sojorn.net/parenting-banner.jpg', 15, 445, 1234),
-- Education Category
('Language Learning Exchange', 'Practice languages, find study partners, and share learning resources. All languages and levels.', 'education', false, 'https://media.sojorn.net/language-avatar.jpg', 'https://media.sojorn.net/language-banner.jpg', 16, 312, 923),
('Book Club Central', 'Monthly book discussions, recommendations, and literary analysis. From classics to contemporary fiction.', 'education', true, 'https://media.sojorn.net/books-avatar.jpg', 'https://media.sojorn.net/books-banner.jpg', 17, 178, 534);
`
_, err = db.Exec(seedSQL)
if err != nil {
log.Printf("Error seeding groups: %v", err)
return
}
fmt.Println("✅ Successfully seeded 15 demo groups across all categories")
// Add sample members
memberSQL := `
INSERT INTO group_members (group_id, user_id, role, joined_at)
SELECT
g.id,
(random() * 100 + 1)::integer as user_id,
CASE
WHEN random() < 0.05 THEN 'owner'
WHEN random() < 0.15 THEN 'admin'
WHEN random() < 0.35 THEN 'moderator'
ELSE 'member'
END as role,
NOW() - (random() * INTERVAL '365 days') as joined_at
FROM groups g
CROSS JOIN generate_series(1, g.member_count)
WHERE g.member_count > 0;
`
_, err = db.Exec(memberSQL)
if err != nil {
log.Printf("Error adding group members: %v", err)
return
}
fmt.Println("✅ Successfully added group members")
// Add sample posts
postSQL := `
INSERT INTO posts (user_id, body, category, created_at, group_id)
SELECT
gm.user_id,
CASE
WHEN random() < 0.3 THEN 'Just discovered this amazing group! Looking forward to connecting with everyone here. #excited'
WHEN random() < 0.6 THEN 'Great discussion happening in this community. What are your thoughts on the latest developments?'
ELSE 'Sharing something interesting I found today. Hope this sparks some good conversations!'
END as body,
'general',
NOW() - (random() * INTERVAL '90 days') as created_at,
gm.group_id
FROM group_members gm
WHERE gm.role != 'owner'
LIMIT 1000;
`
_, err = db.Exec(postSQL)
if err != nil {
log.Printf("Error adding sample posts: %v", err)
return
}
fmt.Println("✅ Successfully added sample posts")
// Update post counts
updateSQL := `
UPDATE groups g
SET post_count = (
SELECT COUNT(*)
FROM posts p
WHERE p.group_id = g.id
);
`
_, err = db.Exec(updateSQL)
if err != nil {
log.Printf("Error updating post counts: %v", err)
return
}
fmt.Println("✅ Successfully updated post counts")
fmt.Println("🎉 Groups seeding completed successfully!")
}

View file

@ -1,140 +0,0 @@
package main
import (
"database/sql"
"fmt"
"log"
"os"
_ "github.com/lib/pq"
)
func main() {
dbURL := os.Getenv("DATABASE_URL")
if dbURL == "" {
log.Fatal("DATABASE_URL environment variable is not set")
}
db, err := sql.Open("postgres", dbURL)
if err != nil {
log.Fatal(err)
}
defer db.Close()
// Get a valid user ID from the users table
var creatorID string
err = db.QueryRow("SELECT id FROM users LIMIT 1").Scan(&creatorID)
if err != nil {
log.Printf("Error getting user ID: %v", err)
return
}
fmt.Printf("👤 Using creator ID: %s\n", creatorID)
// Clear existing groups to start fresh
_, err = db.Exec("DELETE FROM groups")
if err != nil {
log.Printf("Error clearing groups: %v", err)
return
}
// Seed groups with correct column names and valid UUID
seedSQL := `
INSERT INTO groups (
name,
description,
category,
privacy,
avatar_url,
created_by,
member_count,
is_active
) VALUES
-- General Category
('Tech Innovators', 'Discussing the latest in technology, AI, and digital innovation. Share your projects and get feedback from fellow tech enthusiasts.', 'general', 'public', 'https://media.sojorn.net/tech-avatar.jpg', $1, 245, true),
('Creative Minds', 'A space for artists, designers, and creative professionals to share work, get inspiration, and collaborate on projects.', 'general', 'public', 'https://media.sojorn.net/creative-avatar.jpg', $1, 189, true),
-- Hobby Category
('Photography Club', 'Share your best shots, get feedback, learn techniques, and discuss gear. All skill levels welcome!', 'hobby', 'public', 'https://media.sojorn.net/photo-avatar.jpg', $1, 156, true),
('Garden Enthusiasts', 'From balcony gardens to small farms. Share tips, show off your plants, and connect with fellow gardeners.', 'hobby', 'private', 'https://media.sojorn.net/garden-avatar.jpg', $1, 78, true),
('Home Cooking Masters', 'Share recipes, cooking techniques, and kitchen adventures. From beginners to gourmet chefs.', 'hobby', 'public', 'https://media.sojorn.net/cooking-avatar.jpg', $1, 312, true),
-- Sports Category
('Runners United', 'Training tips, race experiences, and running routes. Connect with runners of all levels in your area.', 'sports', 'public', 'https://media.sojorn.net/running-avatar.jpg', $1, 423, true),
('Yoga & Wellness', 'Daily practice sharing, meditation techniques, and wellness discussions. All levels welcome.', 'sports', 'public', 'https://media.sojorn.net/yoga-avatar.jpg', $1, 267, true),
('Cycling Community', 'Road cycling, mountain biking, and urban cycling. Share routes, gear reviews, and group ride info.', 'sports', 'private', 'https://media.sojorn.net/cycling-avatar.jpg', $1, 198, true),
-- Professional Category
('Startup Founders', 'Connect with fellow entrepreneurs, share experiences, and discuss the challenges of building companies.', 'professional', 'private', 'https://media.sojorn.net/startup-avatar.jpg', $1, 134, true),
('Remote Work Professionals', 'Tips, tools, and discussions about working remotely. Share your home office setup and productivity hacks.', 'professional', 'public', 'https://media.sojorn.net/remote-avatar.jpg', $1, 523, true),
('Software Developers', 'Code reviews, tech discussions, career advice, and programming language debates. All languages welcome.', 'professional', 'public', 'https://media.sojorn.net/dev-avatar.jpg', $1, 678, true),
-- Local Business Category
('Local Coffee Shops', 'Supporting local cafés and coffee culture. Share your favorite spots, reviews, and coffee experiences.', 'local_business', 'public', 'https://media.sojorn.net/coffee-avatar.jpg', $1, 89, true),
('Farmers Market Fans', 'Celebrating local farmers markets, farm-to-table eating, and supporting local agriculture.', 'local_business', 'public', 'https://media.sojorn.net/market-avatar.jpg', $1, 156, true),
-- Support Category
('Mental Health Support', 'A safe space to discuss mental health, share coping strategies, and find support. Confidential and respectful.', 'support', 'private', 'https://media.sojorn.net/mental-avatar.jpg', $1, 234, true),
('Parenting Community', 'Share parenting experiences, get advice, and connect with other parents. All parenting stages welcome.', 'support', 'public', 'https://media.sojorn.net/parenting-avatar.jpg', $1, 445, true),
-- Education Category
('Language Learning Exchange', 'Practice languages, find study partners, and share learning resources. All languages and levels.', 'education', 'public', 'https://media.sojorn.net/language-avatar.jpg', $1, 312, true),
('Book Club Central', 'Monthly book discussions, recommendations, and literary analysis. From classics to contemporary fiction.', 'education', 'private', 'https://media.sojorn.net/books-avatar.jpg', $1, 178, true);
`
_, err = db.Exec(seedSQL, creatorID)
if err != nil {
log.Printf("Error seeding groups: %v", err)
return
}
fmt.Println("✅ Successfully seeded 15 demo groups across all categories")
// Verify the seeding
var count int
err = db.QueryRow("SELECT COUNT(*) FROM groups").Scan(&count)
if err != nil {
log.Printf("Error counting groups: %v", err)
return
}
fmt.Printf("🎉 Groups seeding completed! Total groups: %d\n", count)
// Show sample data
rows, err := db.Query(`
SELECT name, category, privacy, member_count
FROM groups
ORDER BY member_count DESC
LIMIT 5;
`)
if err != nil {
log.Printf("Error querying sample groups: %v", err)
return
}
defer rows.Close()
fmt.Println("\n📊 Top 5 groups by member count:")
for rows.Next() {
var name, category, privacy string
var memberCount int
err := rows.Scan(&name, &category, &privacy, &memberCount)
if err != nil {
log.Printf("Error scanning row: %v", err)
continue
}
fmt.Printf(" - %s (%s, %s, %d members)\n", name, category, privacy, memberCount)
}
fmt.Println("\n🚀 DIRECTIVE 1: Groups Validation - STEP 1 COMPLETE")
fmt.Println("✅ Demo groups seeded across all categories")
}

View file

@ -1,171 +0,0 @@
package main
import (
"database/sql"
"fmt"
"log"
"os"
_ "github.com/lib/pq"
)
func main() {
dbURL := os.Getenv("DATABASE_URL")
if dbURL == "" {
log.Fatal("DATABASE_URL environment variable is not set")
}
db, err := sql.Open("postgres", dbURL)
if err != nil {
log.Fatal(err)
}
defer db.Close()
// Clear existing groups to start fresh
_, err = db.Exec("DELETE FROM groups")
if err != nil {
log.Printf("Error clearing groups: %v", err)
return
}
// Seed groups with correct column names
seedSQL := `
INSERT INTO groups (
name,
description,
category,
privacy,
avatar_url,
created_by,
member_count,
is_active
) VALUES
-- General Category
('Tech Innovators', 'Discussing the latest in technology, AI, and digital innovation. Share your projects and get feedback from fellow tech enthusiasts.', 'general', 'public', 'https://media.sojorn.net/tech-avatar.jpg', 1, 245, true),
('Creative Minds', 'A space for artists, designers, and creative professionals to share work, get inspiration, and collaborate on projects.', 'general', 'public', 'https://media.sojorn.net/creative-avatar.jpg', 2, 189, true),
-- Hobby Category
('Photography Club', 'Share your best shots, get feedback, learn techniques, and discuss gear. All skill levels welcome!', 'hobby', 'public', 'https://media.sojorn.net/photo-avatar.jpg', 3, 156, true),
('Garden Enthusiasts', 'From balcony gardens to small farms. Share tips, show off your plants, and connect with fellow gardeners.', 'hobby', 'private', 'https://media.sojorn.net/garden-avatar.jpg', 4, 78, true),
('Home Cooking Masters', 'Share recipes, cooking techniques, and kitchen adventures. From beginners to gourmet chefs.', 'hobby', 'public', 'https://media.sojorn.net/cooking-avatar.jpg', 5, 312, true),
-- Sports Category
('Runners United', 'Training tips, race experiences, and running routes. Connect with runners of all levels in your area.', 'sports', 'public', 'https://media.sojorn.net/running-avatar.jpg', 6, 423, true),
('Yoga & Wellness', 'Daily practice sharing, meditation techniques, and wellness discussions. All levels welcome.', 'sports', 'public', 'https://media.sojorn.net/yoga-avatar.jpg', 7, 267, true),
('Cycling Community', 'Road cycling, mountain biking, and urban cycling. Share routes, gear reviews, and group ride info.', 'sports', 'private', 'https://media.sojorn.net/cycling-avatar.jpg', 8, 198, true),
-- Professional Category
('Startup Founders', 'Connect with fellow entrepreneurs, share experiences, and discuss the challenges of building companies.', 'professional', 'private', 'https://media.sojorn.net/startup-avatar.jpg', 9, 134, true),
('Remote Work Professionals', 'Tips, tools, and discussions about working remotely. Share your home office setup and productivity hacks.', 'professional', 'public', 'https://media.sojorn.net/remote-avatar.jpg', 10, 523, true),
('Software Developers', 'Code reviews, tech discussions, career advice, and programming language debates. All languages welcome.', 'professional', 'public', 'https://media.sojorn.net/dev-avatar.jpg', 11, 678, true),
-- Local Business Category
('Local Coffee Shops', 'Supporting local cafés and coffee culture. Share your favorite spots, reviews, and coffee experiences.', 'local_business', 'public', 'https://media.sojorn.net/coffee-avatar.jpg', 12, 89, true),
('Farmers Market Fans', 'Celebrating local farmers markets, farm-to-table eating, and supporting local agriculture.', 'local_business', 'public', 'https://media.sojorn.net/market-avatar.jpg', 13, 156, true),
-- Support Category
('Mental Health Support', 'A safe space to discuss mental health, share coping strategies, and find support. Confidential and respectful.', 'support', 'private', 'https://media.sojorn.net/mental-avatar.jpg', 14, 234, true),
('Parenting Community', 'Share parenting experiences, get advice, and connect with other parents. All parenting stages welcome.', 'support', 'public', 'https://media.sojorn.net/parenting-avatar.jpg', 15, 445, true),
-- Education Category
('Language Learning Exchange', 'Practice languages, find study partners, and share learning resources. All languages and levels.', 'education', 'public', 'https://media.sojorn.net/language-avatar.jpg', 16, 312, true),
('Book Club Central', 'Monthly book discussions, recommendations, and literary analysis. From classics to contemporary fiction.', 'education', 'private', 'https://media.sojorn.net/books-avatar.jpg', 17, 178, true);
`
_, err = db.Exec(seedSQL)
if err != nil {
log.Printf("Error seeding groups: %v", err)
return
}
fmt.Println("✅ Successfully seeded 15 demo groups across all categories")
// Check if group_members table exists and has correct structure
var membersExists bool
err = db.QueryRow(`
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_name = 'group_members'
);
`).Scan(&membersExists)
if err != nil {
log.Printf("Error checking group_members table: %v", err)
return
}
if !membersExists {
fmt.Println("⚠️ group_members table doesn't exist, skipping member seeding")
} else {
// Add sample members
memberSQL := `
INSERT INTO group_members (group_id, user_id, role, joined_at)
SELECT
g.id,
(random() * 100 + 1)::integer as user_id,
CASE
WHEN random() < 0.05 THEN 'owner'
WHEN random() < 0.15 THEN 'admin'
WHEN random() < 0.35 THEN 'moderator'
ELSE 'member'
END as role,
NOW() - (random() * INTERVAL '365 days') as joined_at
FROM groups g
CROSS JOIN generate_series(1, LEAST(g.member_count, 50))
WHERE g.member_count > 0
ON CONFLICT (group_id, user_id) DO NOTHING;
`
_, err = db.Exec(memberSQL)
if err != nil {
log.Printf("Error adding group members: %v", err)
} else {
fmt.Println("✅ Successfully added group members")
}
}
// Verify the seeding
var count int
err = db.QueryRow("SELECT COUNT(*) FROM groups").Scan(&count)
if err != nil {
log.Printf("Error counting groups: %v", err)
return
}
fmt.Printf("🎉 Groups seeding completed! Total groups: %d\n", count)
// Show sample data
rows, err := db.Query(`
SELECT name, category, privacy, member_count
FROM groups
ORDER BY member_count DESC
LIMIT 5;
`)
if err != nil {
log.Printf("Error querying sample groups: %v", err)
return
}
defer rows.Close()
fmt.Println("\n📊 Top 5 groups by member count:")
for rows.Next() {
var name, category, privacy string
var memberCount int
err := rows.Scan(&name, &category, &privacy, &memberCount)
if err != nil {
log.Printf("Error scanning row: %v", err)
continue
}
fmt.Printf(" - %s (%s, %s, %d members)\n", name, category, privacy, memberCount)
}
}

View file

@ -28,20 +28,4 @@ class ApiConfig {
return raw;
}
/// Wraps external GIF/image URLs (Reddit, GifCities) through the server proxy
/// so the client's IP is never sent to third-party origins.
static String proxyImageUrl(String url) {
return '$baseUrl/image-proxy?url=${Uri.encodeComponent(url)}';
}
/// Returns true if [url] is an external GIF that should be proxied.
static bool needsProxy(String url) {
return url.startsWith('https://i.redd.it/') ||
url.startsWith('https://preview.redd.it/') ||
url.startsWith('https://external-preview.redd.it/') ||
url.startsWith('https://blob.gifcities.org/gifcities/') ||
url.startsWith('https://i.imgur.com/') ||
url.startsWith('https://media.giphy.com/');
}
}

View file

@ -16,7 +16,6 @@ import 'services/secure_chat_service.dart';
import 'services/simple_e2ee_service.dart';
import 'services/key_vault_service.dart';
import 'services/sync_manager.dart';
import 'services/network_service.dart';
import 'package:google_fonts/google_fonts.dart';
import 'theme/app_theme.dart';
import 'providers/theme_provider.dart' as theme_provider;
@ -130,9 +129,6 @@ class _sojornAppState extends ConsumerState<sojornApp> with WidgetsBindingObserv
if (kDebugMode) debugPrint('[APP] initState start ${DateTime.now().toIso8601String()}');
_initDeepLinks();
_listenForAuth();
// Initialize network monitoring
NetworkService().initialize();
if (kDebugMode) debugPrint('[APP] initState sync complete — deferring heavy init');
// Defer heavy work with real delays to avoid jank on first paint
WidgetsBinding.instance.addPostFrameCallback((_) {

View file

@ -1,306 +0,0 @@
import 'package:flutter/material.dart';
enum BeaconCategory {
safetyAlert('Safety Alert', Icons.warning_amber, Colors.red),
communityNeed('Community Need', Icons.volunteer_activism, Colors.green),
lostFound('Lost & Found', Icons.search, Colors.blue),
event('Event', Icons.event, Colors.purple),
mutualAid('Mutual Aid', Icons.handshake, Colors.orange);
const BeaconCategory(this.displayName, this.icon, this.color);
final String displayName;
final IconData icon;
final Color color;
static BeaconCategory fromString(String? value) {
switch (value?.toLowerCase()) {
case 'safety_alert':
case 'safety':
return BeaconCategory.safetyAlert;
case 'community_need':
case 'community':
return BeaconCategory.communityNeed;
case 'lost_found':
case 'lost':
return BeaconCategory.lostFound;
case 'event':
return BeaconCategory.event;
case 'mutual_aid':
case 'mutual':
return BeaconCategory.mutualAid;
default:
return BeaconCategory.safetyAlert;
}
}
}
enum BeaconStatus {
active('Active', Colors.green),
resolved('Resolved', Colors.grey),
archived('Archived', Colors.grey);
const BeaconStatus(this.displayName, this.color);
final String displayName;
final Color color;
static BeaconStatus fromString(String? value) {
switch (value?.toLowerCase()) {
case 'active':
return BeaconStatus.active;
case 'resolved':
return BeaconStatus.resolved;
case 'archived':
return BeaconStatus.archived;
default:
return BeaconStatus.active;
}
}
}
class EnhancedBeacon {
final String id;
final String title;
final String description;
final BeaconCategory category;
final BeaconStatus status;
final double lat;
final double lng;
final String authorId;
final String authorHandle;
final String? authorAvatar;
final bool isVerified;
final bool isOfficialSource;
final String? organizationName;
final DateTime createdAt;
final DateTime? expiresAt;
final int vouchCount;
final int reportCount;
final double confidenceScore;
final String? imageUrl;
final List<String> actionItems;
final String? neighborhood;
final double? radiusMeters;
EnhancedBeacon({
required this.id,
required this.title,
required this.description,
required this.category,
required this.status,
required this.lat,
required this.lng,
required this.authorId,
required this.authorHandle,
this.authorAvatar,
this.isVerified = false,
this.isOfficialSource = false,
this.organizationName,
required this.createdAt,
this.expiresAt,
this.vouchCount = 0,
this.reportCount = 0,
this.confidenceScore = 0.0,
this.imageUrl,
this.actionItems = const [],
this.neighborhood,
this.radiusMeters,
});
factory EnhancedBeacon.fromJson(Map<String, dynamic> json) {
return EnhancedBeacon(
id: json['id'] ?? '',
title: json['title'] ?? '',
description: json['body'] ?? json['description'] ?? '',
category: BeaconCategory.fromString(json['category']),
status: BeaconStatus.fromString(json['status']),
lat: (json['lat'] ?? json['beacon_lat'])?.toDouble() ?? 0.0,
lng: (json['lng'] ?? json['beacon_long'])?.toDouble() ?? 0.0,
authorId: json['author_id'] ?? '',
authorHandle: json['author_handle'] ?? '',
authorAvatar: json['author_avatar'],
isVerified: json['is_verified'] ?? false,
isOfficialSource: json['is_official_source'] ?? false,
organizationName: json['organization_name'],
createdAt: DateTime.parse(json['created_at']),
expiresAt: json['expires_at'] != null ? DateTime.parse(json['expires_at']) : null,
vouchCount: json['vouch_count'] ?? 0,
reportCount: json['report_count'] ?? 0,
confidenceScore: (json['confidence_score'] ?? 0.0).toDouble(),
imageUrl: json['image_url'],
actionItems: (json['action_items'] as List<dynamic>?)?.cast<String>() ?? [],
neighborhood: json['neighborhood'],
radiusMeters: json['radius_meters']?.toDouble(),
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'title': title,
'description': description,
'category': category.name,
'status': status.name,
'lat': lat,
'lng': lng,
'author_id': authorId,
'author_handle': authorHandle,
'author_avatar': authorAvatar,
'is_verified': isVerified,
'is_official_source': isOfficialSource,
'organization_name': organizationName,
'created_at': createdAt.toIso8601String(),
'expires_at': expiresAt?.toIso8601String(),
'vouch_count': vouchCount,
'report_count': reportCount,
"confidence_score": confidenceScore,
'image_url': imageUrl,
'action_items': actionItems,
'neighborhood': neighborhood,
'radius_meters': radiusMeters,
};
}
// Helper methods for UI
bool get isExpired => expiresAt != null && DateTime.now().isAfter(expiresAt!);
bool get isHighConfidence => confidenceScore >= 0.7;
bool get isLowConfidence => confidenceScore < 0.3;
String get confidenceLabel {
if (isHighConfidence) return 'High Confidence';
if (isLowConfidence) return 'Low Confidence';
return 'Medium Confidence';
}
Color get confidenceColor {
if (isHighConfidence) return Colors.green;
if (isLowConfidence) return Colors.red;
return Colors.orange;
}
String get timeAgo {
final now = DateTime.now();
final difference = now.difference(createdAt);
if (difference.inMinutes < 1) return 'Just now';
if (difference.inMinutes < 60) return '${difference.inMinutes}m ago';
if (difference.inHours < 24) return '${difference.inHours}h ago';
if (difference.inDays < 7) return '${difference.inDays}d ago';
return '${createdAt.day}/${createdAt.month}/${createdAt.year}';
}
bool get hasActionItems => actionItems.isNotEmpty;
}
class BeaconCluster {
final List<EnhancedBeacon> beacons;
final double lat;
final double lng;
final int count;
BeaconCluster({
required this.beacons,
required this.lat,
required this.lng,
}) : count = beacons.length;
// Get the most common category in the cluster
BeaconCategory get dominantCategory {
final categoryCount = <BeaconCategory, int>{};
for (final beacon in beacons) {
categoryCount[beacon.category] = (categoryCount[beacon.category] ?? 0) + 1;
}
BeaconCategory? dominant;
int maxCount = 0;
categoryCount.forEach((category, count) {
if (count > maxCount) {
maxCount = count;
dominant = category;
}
});
return dominant ?? BeaconCategory.safetyAlert;
}
// Check if cluster has any official sources
bool get hasOfficialSource {
return beacons.any((b) => b.isOfficialSource);
}
// Get highest priority beacon
EnhancedBeacon get priorityBeacon {
// Priority: Official > High Confidence > Most Recent
final officialBeacons = beacons.where((b) => b.isOfficialSource).toList();
if (officialBeacons.isNotEmpty) {
return officialBeacons.reduce((a, b) => a.createdAt.isAfter(b.createdAt) ? a : b);
}
final highConfidenceBeacons = beacons.where((b) => b.isHighConfidence).toList();
if (highConfidenceBeacons.isNotEmpty) {
return highConfidenceBeacons.reduce((a, b) => a.createdAt.isAfter(b.createdAt) ? a : b);
}
return beacons.reduce((a, b) => a.createdAt.isAfter(b.createdAt) ? a : b);
}
}
class BeaconFilter {
final Set<BeaconCategory> categories;
final Set<BeaconStatus> statuses;
final bool onlyOfficial;
final double? radiusKm;
final String? neighborhood;
const BeaconFilter({
this.categories = const {},
this.statuses = const {},
this.onlyOfficial = false,
this.radiusKm,
this.neighborhood,
});
BeaconFilter copyWith({
Set<BeaconCategory>? categories,
Set<BeaconStatus>? statuses,
bool? onlyOfficial,
double? radiusKm,
String? neighborhood,
}) {
return BeaconFilter(
categories: categories ?? this.categories,
statuses: statuses ?? this.statuses,
onlyOfficial: onlyOfficial ?? this.onlyOfficial,
radiusKm: radiusKm ?? this.radiusKm,
neighborhood: neighborhood ?? this.neighborhood,
);
}
bool matches(EnhancedBeacon beacon) {
// Category filter
if (categories.isNotEmpty && !categories.contains(beacon.category)) {
return false;
}
// Status filter
if (statuses.isNotEmpty && !statuses.contains(beacon.status)) {
return false;
}
// Official filter
if (onlyOfficial && !beacon.isOfficialSource) {
return false;
}
// Neighborhood filter
if (neighborhood != null && beacon.neighborhood != neighborhood) {
return false;
}
return true;
}
}

View file

@ -1,13 +0,0 @@
/// Filter options for the home feed
enum FeedFilter {
all('All Posts', null),
posts('Posts Only', 'post'),
quips('Quips Only', 'quip'),
chains('Chains Only', 'chain'),
beacons('Beacons Only', 'beacon');
final String label;
final String? typeValue;
const FeedFilter(this.label, this.typeValue);
}

View file

@ -1,285 +0,0 @@
import 'package:equatable/equatable.dart';
import 'cluster.dart' show GroupCategory;
export 'cluster.dart' show GroupCategory;
enum GroupRole {
owner('Owner'),
admin('Admin'),
moderator('Moderator'),
member('Member');
const GroupRole(this.displayName);
final String displayName;
static GroupRole fromString(String value) {
return GroupRole.values.firstWhere(
(role) => role.name.toLowerCase() == value.toLowerCase(),
orElse: () => GroupRole.member,
);
}
}
enum JoinRequestStatus {
pending('Pending'),
approved('Approved'),
rejected('Rejected');
const JoinRequestStatus(this.displayName);
final String displayName;
static JoinRequestStatus fromString(String value) {
return JoinRequestStatus.values.firstWhere(
(status) => status.name.toLowerCase() == value.toLowerCase(),
orElse: () => JoinRequestStatus.pending,
);
}
}
class Group extends Equatable {
final String id;
final String name;
final String description;
final GroupCategory category;
final String? avatarUrl;
final String? bannerUrl;
final bool isPrivate;
final String createdBy;
final int memberCount;
final int postCount;
final DateTime createdAt;
final DateTime updatedAt;
final GroupRole? userRole;
final bool isMember;
final bool hasPendingRequest;
const Group({
required this.id,
required this.name,
required this.description,
required this.category,
this.avatarUrl,
this.bannerUrl,
required this.isPrivate,
required this.createdBy,
required this.memberCount,
required this.postCount,
required this.createdAt,
required this.updatedAt,
this.userRole,
this.isMember = false,
this.hasPendingRequest = false,
});
factory Group.fromJson(Map<String, dynamic> json) {
return Group(
id: json['id'] as String,
name: json['name'] as String,
description: json['description'] as String? ?? '',
category: GroupCategory.fromString(json['category'] as String),
avatarUrl: json['avatar_url'] as String?,
bannerUrl: json['banner_url'] as String?,
isPrivate: json['is_private'] as bool? ?? false,
createdBy: json['created_by'] as String,
memberCount: json['member_count'] as int? ?? 0,
postCount: json['post_count'] as int? ?? 0,
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
userRole: json['user_role'] != null
? GroupRole.fromString(json['user_role'] as String)
: null,
isMember: json['is_member'] as bool? ?? false,
hasPendingRequest: json['has_pending_request'] as bool? ?? false,
);
}
Group copyWith({
String? id,
String? name,
String? description,
GroupCategory? category,
String? avatarUrl,
String? bannerUrl,
bool? isPrivate,
String? createdBy,
int? memberCount,
int? postCount,
DateTime? createdAt,
DateTime? updatedAt,
GroupRole? userRole,
bool? isMember,
bool? hasPendingRequest,
}) {
return Group(
id: id ?? this.id,
name: name ?? this.name,
description: description ?? this.description,
category: category ?? this.category,
avatarUrl: avatarUrl ?? this.avatarUrl,
bannerUrl: bannerUrl ?? this.bannerUrl,
isPrivate: isPrivate ?? this.isPrivate,
createdBy: createdBy ?? this.createdBy,
memberCount: memberCount ?? this.memberCount,
postCount: postCount ?? this.postCount,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
userRole: userRole ?? this.userRole,
isMember: isMember ?? this.isMember,
hasPendingRequest: hasPendingRequest ?? this.hasPendingRequest,
);
}
@override
List<Object?> get props => [
id,
name,
description,
category,
avatarUrl,
bannerUrl,
isPrivate,
createdBy,
memberCount,
postCount,
createdAt,
updatedAt,
userRole,
isMember,
hasPendingRequest,
];
String get memberCountText {
if (memberCount >= 1000000) {
return '${(memberCount / 1000000).toStringAsFixed(1)}M members';
} else if (memberCount >= 1000) {
return '${(memberCount / 1000).toStringAsFixed(1)}K members';
}
return '$memberCount members';
}
String get postCountText {
if (postCount >= 1000) {
return '${(postCount / 1000).toStringAsFixed(1)}K posts';
}
return '$postCount posts';
}
}
class GroupMember extends Equatable {
final String id;
final String groupId;
final String userId;
final GroupRole role;
final DateTime joinedAt;
final String? username;
final String? avatarUrl;
const GroupMember({
required this.id,
required this.groupId,
required this.userId,
required this.role,
required this.joinedAt,
this.username,
this.avatarUrl,
});
factory GroupMember.fromJson(Map<String, dynamic> json) {
return GroupMember(
id: json['id'] as String,
groupId: json['group_id'] as String,
userId: json['user_id'] as String,
role: GroupRole.fromString(json['role'] as String),
joinedAt: DateTime.parse(json['joined_at'] as String),
username: json['username'] as String?,
avatarUrl: json['avatar_url'] as String?,
);
}
@override
List<Object?> get props => [
id,
groupId,
userId,
role,
joinedAt,
username,
avatarUrl,
];
}
class JoinRequest extends Equatable {
final String id;
final String groupId;
final String userId;
final JoinRequestStatus status;
final String? message;
final DateTime createdAt;
final DateTime? reviewedAt;
final String? reviewedBy;
final String? username;
final String? avatarUrl;
const JoinRequest({
required this.id,
required this.groupId,
required this.userId,
required this.status,
this.message,
required this.createdAt,
this.reviewedAt,
this.reviewedBy,
this.username,
this.avatarUrl,
});
factory JoinRequest.fromJson(Map<String, dynamic> json) {
return JoinRequest(
id: json['id'] as String,
groupId: json['group_id'] as String,
userId: json['user_id'] as String,
status: JoinRequestStatus.fromString(json['status'] as String),
message: json['message'] as String?,
createdAt: DateTime.parse(json['created_at'] as String),
reviewedAt: json['reviewed_at'] != null
? DateTime.parse(json['reviewed_at'] as String)
: null,
reviewedBy: json['reviewed_by'] as String?,
username: json['username'] as String?,
avatarUrl: json['avatar_url'] as String?,
);
}
@override
List<Object?> get props => [
id,
groupId,
userId,
status,
message,
createdAt,
reviewedAt,
reviewedBy,
username,
avatarUrl,
];
}
class SuggestedGroup extends Equatable {
final Group group;
final String reason;
const SuggestedGroup({
required this.group,
required this.reason,
});
factory SuggestedGroup.fromJson(Map<String, dynamic> json) {
return SuggestedGroup(
group: Group.fromJson(json),
reason: json['reason'] as String? ?? 'Suggested for you',
);
}
@override
List<Object?> get props => [group, reason];
}

View file

@ -1,270 +0,0 @@
import 'package:flutter/material.dart';
import 'dart:convert';
enum ProfileWidgetType {
pinnedPosts('Pinned Posts', Icons.push_pin),
musicWidget('Music Player', Icons.music_note),
photoGrid('Photo Grid', Icons.photo_library),
socialLinks('Social Links', Icons.link),
bio('Bio', Icons.person),
stats('Stats', Icons.bar_chart),
quote('Quote', Icons.format_quote),
beaconActivity('Beacon Activity', Icons.location_on),
customText('Custom Text', Icons.text_fields),
featuredFriends('Featured Friends', Icons.people);
const ProfileWidgetType(this.displayName, this.icon);
final String displayName;
final IconData icon;
static ProfileWidgetType fromString(String? value) {
switch (value) {
case 'pinnedPosts':
return ProfileWidgetType.pinnedPosts;
case 'musicWidget':
return ProfileWidgetType.musicWidget;
case 'photoGrid':
return ProfileWidgetType.photoGrid;
case 'socialLinks':
return ProfileWidgetType.socialLinks;
case 'bio':
return ProfileWidgetType.bio;
case 'stats':
return ProfileWidgetType.stats;
case 'quote':
return ProfileWidgetType.quote;
case 'beaconActivity':
return ProfileWidgetType.beaconActivity;
case 'customText':
return ProfileWidgetType.customText;
case 'featuredFriends':
return ProfileWidgetType.featuredFriends;
default:
return ProfileWidgetType.bio;
}
}
}
class ProfileWidget {
final String id;
final ProfileWidgetType type;
final Map<String, dynamic> config;
final int order;
final bool isEnabled;
ProfileWidget({
required this.id,
required this.type,
required this.config,
required this.order,
this.isEnabled = true,
});
factory ProfileWidget.fromJson(Map<String, dynamic> json) {
return ProfileWidget(
id: json['id'] ?? '',
type: ProfileWidgetType.fromString(json['type']),
config: Map<String, dynamic>.from(json['config'] ?? {}),
order: json['order'] ?? 0,
isEnabled: json['is_enabled'] ?? true,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'type': type.name,
'config': config,
'order': order,
'is_enabled': isEnabled,
};
}
ProfileWidget copyWith({
String? id,
ProfileWidgetType? type,
Map<String, dynamic>? config,
int? order,
bool? isEnabled,
}) {
return ProfileWidget(
id: id ?? this.id,
type: type ?? this.type,
config: config ?? this.config,
order: order ?? this.order,
isEnabled: isEnabled ?? this.isEnabled,
);
}
}
class ProfileLayout {
final List<ProfileWidget> widgets;
final String theme;
final Color? accentColor;
final String? bannerImageUrl;
final DateTime updatedAt;
ProfileLayout({
required this.widgets,
this.theme = 'default',
this.accentColor,
this.bannerImageUrl,
required this.updatedAt,
});
factory ProfileLayout.fromJson(Map<String, dynamic> json) {
return ProfileLayout(
widgets: (json['widgets'] as List<dynamic>?)
?.map((w) => ProfileWidget.fromJson(w as Map<String, dynamic>))
.toList() ?? [],
theme: json['theme'] ?? 'default',
accentColor: json['accent_color'] != null
? Color(int.parse(json['accent_color'].replace('#', '0xFF')))
: null,
bannerImageUrl: json['banner_image_url'],
updatedAt: DateTime.parse(json['updated_at']),
);
}
Map<String, dynamic> toJson() {
return {
'widgets': widgets.map((w) => w.toJson()).toList(),
'theme': theme,
'accent_color': accentColor?.value.toRadixString(16).padLeft(8, '0xFF'),
'banner_image_url': bannerImageUrl,
'updated_at': updatedAt.toIso8601String(),
};
}
ProfileLayout copyWith({
List<ProfileWidget>? widgets,
String? theme,
Color? accentColor,
String? bannerImageUrl,
DateTime? updatedAt,
}) {
return ProfileLayout(
widgets: widgets ?? this.widgets,
theme: theme ?? this.theme,
accentColor: accentColor ?? this.accentColor,
bannerImageUrl: bannerImageUrl ?? this.bannerImageUrl,
updatedAt: updatedAt ?? this.updatedAt,
);
}
}
class ProfileWidgetConstraints {
static const double maxWidth = 400.0;
static const double maxHeight = 300.0;
static const double minSize = 100.0;
static const double defaultSize = 200.0;
static Size getWidgetSize(ProfileWidgetType type) {
switch (type) {
case ProfileWidgetType.pinnedPosts:
return const Size(maxWidth, 150.0);
case ProfileWidgetType.musicWidget:
return const Size(maxWidth, 120.0);
case ProfileWidgetType.photoGrid:
return const Size(maxWidth, 200.0);
case ProfileWidgetType.socialLinks:
return const Size(maxWidth, 80.0);
case ProfileWidgetType.bio:
return const Size(maxWidth, 120.0);
case ProfileWidgetType.stats:
return const Size(maxWidth, 100.0);
case ProfileWidgetType.quote:
return const Size(maxWidth, 150.0);
case ProfileWidgetType.beaconActivity:
return const Size(maxWidth, 180.0);
case ProfileWidgetType.customText:
return const Size(maxWidth, 150.0);
case ProfileWidgetType.featuredFriends:
return const Size(maxWidth, 120.0);
}
}
static bool isValidSize(Size size) {
return size.width >= minSize &&
size.width <= maxWidth &&
size.height >= minSize &&
size.height <= maxHeight;
}
}
class ProfileTheme {
final String name;
final Color primaryColor;
final Color backgroundColor;
final Color textColor;
final Color accentColor;
final String fontFamily;
const ProfileTheme({
required this.name,
required this.primaryColor,
required this.backgroundColor,
required this.textColor,
required this.accentColor,
required this.fontFamily,
});
static const List<ProfileTheme> availableThemes = [
ProfileTheme(
name: 'default',
primaryColor: Colors.blue,
backgroundColor: Colors.white,
textColor: Colors.black87,
accentColor: Colors.blueAccent,
fontFamily: 'Roboto',
),
ProfileTheme(
name: 'dark',
primaryColor: Colors.grey,
backgroundColor: Colors.black87,
textColor: Colors.white,
accentColor: Colors.blueAccent,
fontFamily: 'Roboto',
),
ProfileTheme(
name: 'ocean',
primaryColor: Colors.cyan,
backgroundColor: Color(0xFFF0F8FF),
textColor: Colors.black87,
accentColor: Colors.teal,
fontFamily: 'Roboto',
),
ProfileTheme(
name: 'sunset',
primaryColor: Colors.orange,
backgroundColor: Color(0xFFFFF3E0),
textColor: Colors.black87,
accentColor: Colors.deepOrange,
fontFamily: 'Roboto',
),
ProfileTheme(
name: 'forest',
primaryColor: Colors.green,
backgroundColor: Color(0xFFF1F8E9),
textColor: Colors.black87,
accentColor: Colors.lightGreen,
fontFamily: 'Roboto',
),
ProfileTheme(
name: 'royal',
primaryColor: Colors.purple,
backgroundColor: Color(0xFFF3E5F5),
textColor: Colors.black87,
accentColor: Colors.deepPurple,
fontFamily: 'Roboto',
),
];
static ProfileTheme getThemeByName(String name) {
return availableThemes.firstWhere(
(theme) => theme.name == name,
orElse: () => availableThemes.first,
);
}
}

View file

@ -1,43 +1,30 @@
import 'package:flutter/material.dart';
/// Type of overlay item on a Quip video.
enum QuipOverlayType { text, sticker }
/// A single overlay item (text or sticker/emoji) placed on a Quip video.
/// Position is normalized (0.01.0) relative to the video dimensions so it
/// renders correctly at any screen size.
class QuipOverlayItem {
final String id; // unique identifier for widget keying
final QuipOverlayType type;
final String content; // text string or emoji/sticker character
final Color color; // text color (default white)
final Offset position; // normalized 0.01.0
/// Model for text overlays on Quip videos
class QuipTextOverlay {
final String text;
final Color color;
final Offset position; // Normalized 0.0-1.0 coordinates
final double scale;
final double rotation; // radians
final double rotation; // In radians
const QuipOverlayItem({
required this.id,
required this.type,
required this.content,
this.color = Colors.white,
this.position = const Offset(0.5, 0.5),
const QuipTextOverlay({
required this.text,
required this.color,
required this.position,
this.scale = 1.0,
this.rotation = 0.0,
});
QuipOverlayItem copyWith({
String? id,
QuipOverlayType? type,
String? content,
QuipTextOverlay copyWith({
String? text,
Color? color,
Offset? position,
double? scale,
double? rotation,
}) {
return QuipOverlayItem(
id: id ?? this.id,
type: type ?? this.type,
content: content ?? this.content,
return QuipTextOverlay(
text: text ?? this.text,
color: color ?? this.color,
position: position ?? this.position,
scale: scale ?? this.scale,
@ -47,9 +34,7 @@ class QuipOverlayItem {
Map<String, dynamic> toJson() {
return {
'id': id,
'type': type.name,
'content': content,
'text': text,
'color': color.value,
'position': {'x': position.dx, 'y': position.dy},
'scale': scale,
@ -57,13 +42,9 @@ class QuipOverlayItem {
};
}
factory QuipOverlayItem.fromJson(Map<String, dynamic> json) {
return QuipOverlayItem(
id: json['id'] as String? ?? UniqueKey().toString(),
type: QuipOverlayType.values.byName(
(json['type'] as String?) ?? 'text',
),
content: (json['content'] ?? json['text'] ?? '') as String,
factory QuipTextOverlay.fromJson(Map<String, dynamic> json) {
return QuipTextOverlay(
text: json['text'] as String,
color: Color(json['color'] as int),
position: Offset(
(json['position']['x'] as num).toDouble(),
@ -75,11 +56,7 @@ class QuipOverlayItem {
}
}
/// Backward-compat alias so existing screens that reference QuipTextOverlay
/// do not require immediate migration.
typedef QuipTextOverlay = QuipOverlayItem;
/// Placeholder for music track metadata.
/// Placeholder for future music track functionality
class MusicTrack {
final String id;
final String name;

View file

@ -1,277 +0,0 @@
import 'package:flutter/material.dart';
enum RepostType {
standard('Repost', Icons.repeat),
quote('Quote', Icons.format_quote),
boost('Boost', Icons.rocket_launch),
amplify('Amplify', Icons.trending_up);
const RepostType(this.displayName, this.icon);
final String displayName;
final IconData icon;
static RepostType fromString(String? value) {
switch (value) {
case 'standard':
return RepostType.standard;
case 'quote':
return RepostType.quote;
case 'boost':
return RepostType.boost;
case 'amplify':
return RepostType.amplify;
default:
return RepostType.standard;
}
}
}
class Repost {
final String id;
final String originalPostId;
final String authorId;
final String authorHandle;
final String? authorAvatar;
final RepostType type;
final String? comment;
final DateTime createdAt;
final int boostCount;
final int amplificationScore;
final bool isAmplified;
final Map<String, dynamic>? metadata;
Repost({
required this.id,
required this.originalPostId,
required this.authorId,
required this.authorHandle,
this.authorAvatar,
required this.type,
this.comment,
required this.createdAt,
this.boostCount = 0,
this.amplificationScore = 0,
this.isAmplified = false,
this.metadata,
});
factory Repost.fromJson(Map<String, dynamic> json) {
return Repost(
id: json['id'] ?? '',
originalPostId: json['original_post_id'] ?? '',
authorId: json['author_id'] ?? '',
authorHandle: json['author_handle'] ?? '',
authorAvatar: json['author_avatar'],
type: RepostType.fromString(json['type']),
comment: json['comment'],
createdAt: DateTime.parse(json['created_at']),
boostCount: json['boost_count'] ?? 0,
amplificationScore: json['amplification_score'] ?? 0,
isAmplified: json['is_amplified'] ?? false,
metadata: json['metadata'],
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'original_post_id': originalPostId,
'author_id': authorId,
'author_handle': authorHandle,
'author_avatar': authorAvatar,
'type': type.name,
'comment': comment,
'created_at': createdAt.toIso8601String(),
'boost_count': boostCount,
'amplification_score': amplificationScore,
'is_amplified': isAmplified,
'metadata': metadata,
};
}
String get timeAgo {
final now = DateTime.now();
final difference = now.difference(createdAt);
if (difference.inMinutes < 1) return 'Just now';
if (difference.inMinutes < 60) return '${difference.inMinutes}m ago';
if (difference.inHours < 24) return '${difference.inHours}h ago';
if (difference.inDays < 7) return '${difference.inDays}d ago';
return '${createdAt.day}/${createdAt.month}/${createdAt.year}';
}
}
class AmplificationMetrics {
final int totalReach;
final int engagementCount;
final double engagementRate;
final int newFollowers;
final int shares;
final int comments;
final int likes;
final DateTime lastUpdated;
AmplificationMetrics({
required this.totalReach,
required this.engagementCount,
required this.engagementRate,
required this.newFollowers,
required this.shares,
required this.comments,
required this.likes,
required this.lastUpdated,
});
factory AmplificationMetrics.fromJson(Map<String, dynamic> json) {
return AmplificationMetrics(
totalReach: json['total_reach'] ?? 0,
engagementCount: json['engagement_count'] ?? 0,
engagementRate: (json['engagement_rate'] ?? 0.0).toDouble(),
newFollowers: json['new_followers'] ?? 0,
shares: json['shares'] ?? 0,
comments: json['comments'] ?? 0,
likes: json['likes'] ?? 0,
lastUpdated: DateTime.parse(json['last_updated']),
);
}
Map<String, dynamic> toJson() {
return {
'total_reach': totalReach,
'engagement_count': engagementCount,
'engagement_rate': engagementRate,
'new_followers': newFollowers,
'shares': shares,
'comments': comments,
'likes': likes,
'last_updated': lastUpdated.toIso8601String(),
};
}
}
class FeedAmplificationRule {
final String id;
final String name;
final String description;
final RepostType type;
final double weightMultiplier;
final int minBoostScore;
final int maxDailyBoosts;
final bool isActive;
final DateTime createdAt;
FeedAmplificationRule({
required this.id,
required this.name,
required this.description,
required this.type,
required this.weightMultiplier,
required this.minBoostScore,
required this.maxDailyBoosts,
required this.isActive,
required this.createdAt,
});
factory FeedAmplificationRule.fromJson(Map<String, dynamic> json) {
return FeedAmplificationRule(
id: json['id'] ?? '',
name: json['name'] ?? '',
description: json['description'] ?? '',
type: RepostType.fromString(json['type']),
weightMultiplier: (json['weight_multiplier'] ?? 1.0).toDouble(),
minBoostScore: json['min_boost_score'] ?? 0,
maxDailyBoosts: json['max_daily_boosts'] ?? 5,
isActive: json['is_active'] ?? true,
createdAt: DateTime.parse(json['created_at']),
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'description': description,
'type': type.name,
'weight_multiplier': weightMultiplier,
'min_boost_score': minBoostScore,
'max_daily_boosts': maxDailyBoosts,
'is_active': isActive,
'created_at': createdAt.toIso8601String(),
};
}
}
class AmplificationAnalytics {
final String postId;
final List<AmplificationMetrics> metrics;
final List<Repost> reposts;
final int totalAmplification;
final double amplificationRate;
final Map<RepostType, int> repostCounts;
AmplificationAnalytics({
required this.postId,
required this.metrics,
required this.reposts,
required this.totalAmplification,
required this.amplificationRate,
required this.repostCounts,
});
factory AmplificationAnalytics.fromJson(Map<String, dynamic> json) {
final repostCountsMap = <RepostType, int>{};
final repostCountsJson = json['repost_counts'] as Map<String, dynamic>? ?? {};
repostCountsJson.forEach((type, count) {
final repostType = RepostType.fromString(type);
repostCountsMap[repostType] = count as int;
});
return AmplificationAnalytics(
postId: json['post_id'] ?? '',
metrics: (json['metrics'] as List<dynamic>?)
?.map((m) => AmplificationMetrics.fromJson(m as Map<String, dynamic>))
.toList() ?? [],
reposts: (json['reposts'] as List<dynamic>?)
?.map((r) => Repost.fromJson(r as Map<String, dynamic>))
.toList() ?? [],
totalAmplification: json['total_amplification'] ?? 0,
amplificationRate: (json['amplification_rate'] ?? 0.0).toDouble(),
repostCounts: repostCountsMap,
);
}
Map<String, dynamic> toJson() {
final repostCountsJson = <String, int>{};
repostCounts.forEach((type, count) {
repostCountsJson[type.name] = count;
});
return {
'post_id': postId,
'metrics': metrics.map((m) => m.toJson()).toList(),
'reposts': reposts.map((r) => r.toJson()).toList(),
'total_amplification': totalAmplification,
'amplification_rate': amplificationRate,
'repost_counts': repostCountsJson,
};
}
int get totalReposts => reposts.length;
RepostType? get mostEffectiveType {
if (repostCounts.isEmpty) return null;
return repostCounts.entries.reduce((a, b) =>
a.value > b.value ? a : b
).key;
}
double get averageEngagementRate {
if (metrics.isEmpty) return 0.0;
final totalRate = metrics.fold(0.0, (sum, metric) => sum + metric.engagementRate);
return totalRate / metrics.length;
}
}

View file

@ -42,12 +42,7 @@ class QuipUploadNotifier extends Notifier<QuipUploadState> {
return QuipUploadState(isUploading: false, progress: 0.0);
}
Future<void> startUpload(
File videoFile,
String caption, {
double? thumbnailTimestampMs,
String? overlayJson,
}) async {
Future<void> startUpload(File videoFile, String caption, {double? thumbnailTimestampMs}) async {
try {
state = state.copyWith(
isUploading: true, progress: 0.0, error: null, successMessage: null);
@ -110,11 +105,10 @@ class QuipUploadNotifier extends Notifier<QuipUploadState> {
// Publish post via Go API
await ApiService.instance.publishPost(
body: caption.isNotEmpty ? caption : ' ',
body: caption,
videoUrl: videoUrl,
thumbnailUrl: thumbnailUrl,
categoryId: null, // Default
overlayJson: overlayJson,
);
// Trigger feed refresh

View file

@ -1,134 +0,0 @@
import 'dart:convert';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:http/http.dart' as http;
const _cdnBase = 'https://reactions.sojorn.net';
/// Parsed reaction package ready for use by [ReactionPicker].
class ReactionPackage {
final List<String> tabOrder;
final Map<String, List<String>> reactionSets; // tabId list of identifiers (URL or emoji)
final Map<String, String> folderCredits; // tabId credit markdown
const ReactionPackage({
required this.tabOrder,
required this.reactionSets,
required this.folderCredits,
});
}
/// Riverpod provider that loads reaction sets once per app session.
/// Priority: CDN index.json local assets hardcoded emoji.
final reactionPackageProvider = FutureProvider<ReactionPackage>((ref) async {
// 1. Try CDN
try {
final response = await http
.get(Uri.parse('$_cdnBase/index.json'))
.timeout(const Duration(seconds: 5));
if (response.statusCode == 200) {
final data = jsonDecode(response.body) as Map<String, dynamic>;
final tabsRaw =
(data['tabs'] as List? ?? []).whereType<Map<String, dynamic>>();
final tabOrder = <String>['emoji'];
final reactionSets = <String, List<String>>{'emoji': _defaultEmoji};
final folderCredits = <String, String>{};
for (final tab in tabsRaw) {
final id = tab['id'] as String? ?? '';
if (id.isEmpty || id == 'emoji') continue;
final credit = tab['credit'] as String?;
final files =
(tab['reactions'] as List? ?? []).whereType<String>().toList();
final urls = files.map((f) => '$_cdnBase/$id/$f').toList();
tabOrder.add(id);
reactionSets[id] = urls;
if (credit != null && credit.isNotEmpty) {
folderCredits[id] = credit;
}
}
// Only return CDN result if we got actual image tabs (not just emoji)
if (tabOrder.length > 1) {
return ReactionPackage(
tabOrder: tabOrder,
reactionSets: reactionSets,
folderCredits: folderCredits,
);
}
}
} catch (_) {}
// 2. Fallback: local assets
try {
final manifest = await AssetManifest.loadFromAssetBundle(rootBundle);
final assetPaths = manifest.listAssets();
final reactionAssets = assetPaths.where((path) {
final lp = path.toLowerCase();
return lp.startsWith('assets/reactions/') &&
(lp.endsWith('.png') ||
lp.endsWith('.svg') ||
lp.endsWith('.webp') ||
lp.endsWith('.jpg') ||
lp.endsWith('.jpeg') ||
lp.endsWith('.gif'));
}).toList();
if (reactionAssets.isNotEmpty) {
final tabOrder = <String>['emoji'];
final reactionSets = <String, List<String>>{'emoji': _defaultEmoji};
final folderCredits = <String, String>{};
for (final path in reactionAssets) {
final parts = path.split('/');
if (parts.length >= 4) {
final folder = parts[2];
if (!reactionSets.containsKey(folder)) {
tabOrder.add(folder);
reactionSets[folder] = [];
try {
final creditPath = 'assets/reactions/$folder/credit.md';
if (assetPaths.contains(creditPath)) {
folderCredits[folder] =
await rootBundle.loadString(creditPath);
}
} catch (_) {}
}
reactionSets[folder]!.add(path);
}
}
for (final key in reactionSets.keys) {
if (key != 'emoji') {
reactionSets[key]!
.sort((a, b) => a.split('/').last.compareTo(b.split('/').last));
}
}
return ReactionPackage(
tabOrder: tabOrder,
reactionSets: reactionSets,
folderCredits: folderCredits,
);
}
} catch (_) {}
// 3. Hardcoded emoji fallback
return ReactionPackage(
tabOrder: ['emoji'],
reactionSets: {'emoji': _defaultEmoji},
folderCredits: {},
);
});
const _defaultEmoji = [
'❤️', '👍', '😂', '😮', '😢', '😡',
'🎉', '🔥', '👏', '🙏', '💯', '🤔',
'😍', '🤣', '😊', '👌', '🙌', '💪',
'🎯', '', '', '🌟', '💫', '☀️',
];

View file

@ -1,5 +1,9 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../services/media/ffmpeg.dart';
import 'package:path_provider/path_provider.dart';
import '../../services/image_upload_service.dart';
import '../../providers/api_provider.dart';
class QuipRepairScreen extends ConsumerStatefulWidget {
@ -10,6 +14,8 @@ class QuipRepairScreen extends ConsumerStatefulWidget {
}
class _QuipRepairScreenState extends ConsumerState<QuipRepairScreen> {
final ImageUploadService _uploadService = ImageUploadService();
List<Map<String, dynamic>> _brokenQuips = [];
bool _isLoading = false;
bool _isRepairing = false;
@ -22,69 +28,126 @@ class _QuipRepairScreenState extends ConsumerState<QuipRepairScreen> {
}
Future<void> _fetchBrokenQuips() async {
setState(() { _isLoading = true; _statusMessage = null; });
setState(() => _isLoading = true);
try {
final api = ref.read(apiServiceProvider);
final data = await api.callGoApi('/admin/quips/broken', method: 'GET');
final quips = (data['quips'] as List?)?.cast<Map<String, dynamic>>() ?? [];
if (mounted) setState(() => _brokenQuips = quips);
} catch (e) {
if (mounted) {
setState(() => _statusMessage = 'Error loading broken quips: $e');
setState(() {
_brokenQuips = [];
_statusMessage =
'Quip repair is unavailable (Go API migration pending).';
});
}
} catch (e) {
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error: $e')));
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
Future<void> _repairQuip(Map<String, dynamic> quip) async {
setState(() => _isRepairing = true);
setState(() {
_isRepairing = false;
_statusMessage =
'Quip repair is unavailable (Go API migration pending).';
});
return;
try {
final videoUrl = quip['video_url'] as String;
if (videoUrl.isEmpty) throw "No Video URL";
// Get signed URL for the video if needed (assuming public/signed handling elsewhere)
// FFmpeg typically handles public URLs. If private R2, we need a signed URL.
final api = ref.read(apiServiceProvider);
await api.callGoApi('/admin/quips/${quip['id']}/repair', method: 'POST');
final signedVideoUrl = await api.getSignedMediaUrl(videoUrl);
if (signedVideoUrl == null) throw "Could not sign video URL";
// Generate thumbnail
final tempDir = await getTemporaryDirectory();
final thumbPath = '${tempDir.path}/repair_thumb_${quip['id']}.jpg';
// Use executeWithArguments to handle URLs with special characters safely.
// Added reconnect flags for better handling of network streams.
final session = await FFmpegKit.executeWithArguments([
'-y',
'-user_agent', 'SojornApp/1.0',
'-reconnect', '1',
'-reconnect_at_eof', '1',
'-reconnect_streamed', '1',
'-reconnect_delay_max', '4294',
'-i', signedVideoUrl,
'-ss', '00:00:01',
'-vframes', '1',
'-q:v', '5',
thumbPath
]);
final returnCode = await session.getReturnCode();
if (!ReturnCode.isSuccess(returnCode)) {
final logs = await session.getAllLogsAsString();
// Print in chunks if it's too long for some logcats
// Extract the last error message from logs if possible
String errorDetail = "FFmpeg failed (Code: $returnCode)";
if (logs != null && logs.contains('Error')) {
errorDetail = logs.substring(logs.lastIndexOf('Error')).split('\n').first;
}
throw errorDetail;
}
final thumbFile = File(thumbPath);
if (!await thumbFile.exists()) throw "Thumbnail file creation failed";
// Upload
final thumbUrl = await _uploadService.uploadImage(thumbFile);
// Update Post (TODO: migrate to Go API)
if (mounted) {
setState(() {
_brokenQuips.removeWhere((q) => q['id'] == quip['id']);
_statusMessage = 'Fixed: ${quip['id']}';
_statusMessage = "Fixed ${quip['id']}";
});
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Repair failed: $e')),
);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Repair Failed: $e')));
}
} finally {
if (mounted) setState(() => _isRepairing = false);
if (mounted) {
setState(() {
_isRepairing = false;
});
}
}
}
Future<void> _repairAll() async {
// Clone list to avoid modification issues
final list = List<Map<String, dynamic>>.from(_brokenQuips);
for (final quip in list) {
if (!mounted) break;
await _repairQuip(quip);
}
if (mounted) setState(() => _statusMessage = 'Repair all complete');
if (mounted) {
setState(() => _statusMessage = "Repair All Complete");
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Repair Thumbnails'),
title: const Text("Repair Thumbnails"),
actions: [
IconButton(
icon: const Icon(Icons.refresh),
onPressed: _isLoading ? null : _fetchBrokenQuips,
tooltip: 'Reload',
),
if (_brokenQuips.isNotEmpty && !_isRepairing)
IconButton(
icon: const Icon(Icons.build),
onPressed: _repairAll,
tooltip: 'Repair All',
),
tooltip: "Repair All",
)
],
),
body: Column(
@ -98,30 +161,25 @@ class _QuipRepairScreenState extends ConsumerState<QuipRepairScreen> {
),
Expanded(
child: _isLoading
? const Center(child: CircularProgressIndicator())
: _brokenQuips.isEmpty
? const Center(child: Text('No missing thumbnails found.'))
: ListView.builder(
itemCount: _brokenQuips.length,
itemBuilder: (context, index) {
final item = _brokenQuips[index];
return ListTile(
leading: const Icon(Icons.videocam_off),
title: Text(item['id'] as String? ?? ''),
subtitle: Text(item['created_at']?.toString() ?? ''),
trailing: _isRepairing
? const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: 2),
)
: IconButton(
icon: const Icon(Icons.auto_fix_high),
onPressed: () => _repairQuip(item),
),
);
},
),
? const Center(child: CircularProgressIndicator())
: _brokenQuips.isEmpty
? const Center(child: Text("No missing thumbnails found."))
: ListView.builder(
itemCount: _brokenQuips.length,
itemBuilder: (context, index) {
final item = _brokenQuips[index];
return ListTile(
title: Text(item['body'] ?? "No Caption"),
subtitle: Text(item['created_at'].toString()),
trailing: _isRepairing
? null
: IconButton(
icon: const Icon(Icons.refresh),
onPressed: () => _repairQuip(item),
),
);
},
),
),
],
),

View file

@ -1,280 +0,0 @@
import 'dart:io';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:video_player/video_player.dart';
import '../../config/api_config.dart';
import '../../providers/api_provider.dart';
/// Result returned when the user picks an audio track.
class AudioTrack {
final String path; // local file path OR network URL (feed directly to ffmpeg)
final String title;
const AudioTrack({required this.path, required this.title});
}
/// Two-tab screen for picking background audio.
///
/// Tab 1 (Device): opens the file picker for local audio files.
/// Tab 2 (Library): browses the Funkwhale library via the Go proxy.
///
/// Navigator.push returns an [AudioTrack] when the user picks a track,
/// or null if they cancelled.
class AudioLibraryScreen extends ConsumerStatefulWidget {
const AudioLibraryScreen({super.key});
@override
ConsumerState<AudioLibraryScreen> createState() => _AudioLibraryScreenState();
}
class _AudioLibraryScreenState extends ConsumerState<AudioLibraryScreen>
with SingleTickerProviderStateMixin {
late final TabController _tabController;
// Library tab state
final _searchController = TextEditingController();
List<Map<String, dynamic>> _tracks = [];
bool _loading = false;
bool _unavailable = false;
String? _previewingId;
VideoPlayerController? _previewController;
@override
void initState() {
super.initState();
_tabController = TabController(length: 2, vsync: this);
_fetchTracks('');
}
@override
void dispose() {
_tabController.dispose();
_searchController.dispose();
_previewController?.dispose();
super.dispose();
}
Future<void> _fetchTracks(String q) async {
setState(() { _loading = true; _unavailable = false; });
try {
final api = ref.read(apiServiceProvider);
final data = await api.callGoApi('/audio/library', method: 'GET', queryParams: {'q': q});
final results = (data['results'] as List?)?.cast<Map<String, dynamic>>() ?? [];
setState(() {
_tracks = results;
// 503 is returned as an empty list with an "error" key
_unavailable = data['error'] != null && results.isEmpty;
});
} catch (_) {
setState(() { _unavailable = true; _tracks = []; });
} finally {
if (mounted) setState(() => _loading = false);
}
}
Future<void> _togglePreview(Map<String, dynamic> track) async {
final id = track['id']?.toString() ?? '';
if (_previewingId == id) {
// Stop preview
await _previewController?.pause();
await _previewController?.dispose();
setState(() { _previewController = null; _previewingId = null; });
return;
}
await _previewController?.dispose();
setState(() { _previewingId = id; _previewController = null; });
// Use the Go proxy listen URL VideoPlayerController handles it as audio
final listenUrl = '${ApiConfig.baseUrl}/audio/library/$id/listen';
final controller = VideoPlayerController.networkUrl(Uri.parse(listenUrl));
try {
await controller.initialize();
await controller.play();
if (mounted) setState(() => _previewController = controller);
} catch (_) {
await controller.dispose();
if (mounted) {
setState(() { _previewingId = null; });
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Preview unavailable for this track')),
);
}
}
}
void _useTrack(Map<String, dynamic> track) {
final id = track['id']?.toString() ?? '';
final title = (track['title'] as String?) ?? 'Unknown Track';
final artist = (track['artist']?['name'] as String?) ?? '';
final displayTitle = artist.isNotEmpty ? '$title$artist' : title;
final listenUrl = '${ApiConfig.baseUrl}/audio/library/$id/listen';
Navigator.of(context).pop(AudioTrack(path: listenUrl, title: displayTitle));
}
Future<void> _pickDeviceAudio() async {
final result = await FilePicker.platform.pickFiles(
type: FileType.audio,
allowMultiple: false,
);
if (result != null && result.files.isNotEmpty && mounted) {
final file = result.files.first;
final path = file.path;
if (path != null) {
Navigator.of(context).pop(AudioTrack(
path: path,
title: file.name,
));
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Add Music'),
bottom: TabBar(
controller: _tabController,
tabs: const [
Tab(icon: Icon(Icons.smartphone), text: 'Device'),
Tab(icon: Icon(Icons.library_music), text: 'Library'),
],
),
),
body: TabBarView(
controller: _tabController,
children: [
_DeviceTab(onPick: _pickDeviceAudio),
_libraryTab(),
],
),
);
}
Widget _libraryTab() {
return Column(
children: [
Padding(
padding: const EdgeInsets.all(12),
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Search tracks...',
prefixIcon: const Icon(Icons.search),
suffixIcon: IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
_fetchTracks('');
},
),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
contentPadding: const EdgeInsets.symmetric(vertical: 10, horizontal: 16),
),
textInputAction: TextInputAction.search,
onSubmitted: _fetchTracks,
),
),
Expanded(child: _libraryBody()),
],
);
}
Widget _libraryBody() {
if (_loading) return const Center(child: CircularProgressIndicator());
if (_unavailable) {
return const Center(
child: Padding(
padding: EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.cloud_off, size: 48, color: Colors.grey),
SizedBox(height: 16),
Text(
'Music library coming soon',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
),
SizedBox(height: 8),
Text(
'Use the Device tab to add your own audio, or check back after the library is deployed.',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey),
),
],
),
),
);
}
if (_tracks.isEmpty) {
return const Center(child: Text('No tracks found'));
}
return ListView.separated(
itemCount: _tracks.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (context, i) {
final track = _tracks[i];
final id = track['id']?.toString() ?? '';
final title = (track['title'] as String?) ?? 'Unknown';
final artist = (track['artist']?['name'] as String?) ?? '';
final duration = track['duration'] as int? ?? 0;
final mins = (duration ~/ 60).toString().padLeft(2, '0');
final secs = (duration % 60).toString().padLeft(2, '0');
final isPreviewing = _previewingId == id;
return ListTile(
leading: const Icon(Icons.music_note),
title: Text(title, maxLines: 1, overflow: TextOverflow.ellipsis),
subtitle: Text('$artist$mins:$secs', style: const TextStyle(fontSize: 12)),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: Icon(isPreviewing ? Icons.stop : Icons.play_arrow),
tooltip: isPreviewing ? 'Stop' : 'Preview',
onPressed: () => _togglePreview(track),
),
TextButton(
onPressed: () => _useTrack(track),
child: const Text('Use'),
),
],
),
);
},
);
}
}
class _DeviceTab extends StatelessWidget {
final VoidCallback onPick;
const _DeviceTab({required this.onPick});
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.folder_open, size: 64, color: Colors.grey),
const SizedBox(height: 16),
const Text('Pick an audio file from your device',
style: TextStyle(fontSize: 16)),
const SizedBox(height: 8),
const Text('MP3, AAC, WAV, FLAC and more',
style: TextStyle(color: Colors.grey)),
const SizedBox(height: 24),
ElevatedButton.icon(
onPressed: onPick,
icon: const Icon(Icons.audio_file),
label: const Text('Browse Files'),
),
],
),
);
}
}

View file

@ -34,6 +34,12 @@ class _SignInScreenState extends ConsumerState<SignInScreen> {
String? _storedPassword;
String? _altchaToken;
// Turnstile site key from environment or default production key
static const String _turnstileSiteKey = String.fromEnvironment(
'TURNSTILE_SITE_KEY',
defaultValue: '0x4AAAAAACYFlz_g513d6xAf', // Cloudflare production key
);
static const _savedEmailKey = 'saved_login_email';
static const _savedPasswordKey = 'saved_login_password';

View file

@ -39,6 +39,12 @@ class _SignUpScreenState extends ConsumerState<SignUpScreen> {
int? _birthMonth;
int? _birthYear;
// Turnstile site key from environment or default production key
static const String _turnstileSiteKey = String.fromEnvironment(
'TURNSTILE_SITE_KEY',
defaultValue: '0x4AAAAAACYFlz_g513d6xAf', // Cloudflare production key
);
@override
void dispose() {
_emailController.dispose();
@ -427,7 +433,7 @@ class _SignUpScreenState extends ConsumerState<SignUpScreen> {
),
const SizedBox(height: AppTheme.spacingLg),
// ALTCHA verification
// Turnstile CAPTCHA
Container(
decoration: BoxDecoration(
border: Border.all(

View file

@ -12,7 +12,6 @@ import '../../models/beacon.dart';
import '../../models/cluster.dart';
import '../../models/board_entry.dart';
import '../../models/local_intel.dart';
import '../../models/group.dart' as group_models;
import '../../services/api_service.dart';
import '../../services/auth_service.dart';
import '../../services/local_intel_service.dart';
@ -171,7 +170,7 @@ class BeaconScreenState extends ConsumerState<BeaconScreen> with TickerProviderS
if (!_locationPermissionGranted) return;
setState(() => _isLoadingLocation = true);
try {
final position = await Geolocator.getCurrentPosition(desiredAccuracy: LocationAccuracy.low);
final position = await Geolocator.getCurrentPosition(desiredAccuracy: LocationAccuracy.high);
if (mounted) {
setState(() {
_userLocation = LatLng(position.latitude, position.longitude);
@ -2408,19 +2407,19 @@ class _PulsingLocationIndicatorState extends State<_PulsingLocationIndicator>
}
// Create Group inline form
class _CreateGroupInline extends ConsumerStatefulWidget {
class _CreateGroupInline extends StatefulWidget {
final VoidCallback onCreated;
const _CreateGroupInline({required this.onCreated});
@override
ConsumerState<_CreateGroupInline> createState() => _CreateGroupInlineState();
State<_CreateGroupInline> createState() => _CreateGroupInlineState();
}
class _CreateGroupInlineState extends ConsumerState<_CreateGroupInline> {
class _CreateGroupInlineState extends State<_CreateGroupInline> {
final _nameCtrl = TextEditingController();
final _descCtrl = TextEditingController();
bool _privacy = false;
group_models.GroupCategory _category = group_models.GroupCategory.general;
String _privacy = 'public';
GroupCategory _category = GroupCategory.general;
bool _submitting = false;
@override
@ -2430,12 +2429,11 @@ class _CreateGroupInlineState extends ConsumerState<_CreateGroupInline> {
if (_nameCtrl.text.trim().isEmpty) return;
setState(() => _submitting = true);
try {
final api = ref.read(apiServiceProvider);
await api.createGroup(
await ApiService.instance.createGroup(
name: _nameCtrl.text.trim(),
description: _descCtrl.text.trim(),
category: _category,
isPrivate: _privacy,
privacy: _privacy,
category: _category.value,
);
widget.onCreated();
} catch (e) {
@ -2494,12 +2492,12 @@ class _CreateGroupInlineState extends ConsumerState<_CreateGroupInline> {
Row(children: [
Text('Visibility:', style: TextStyle(fontSize: 13, color: SojornColors.basicBlack.withValues(alpha: 0.6))),
const SizedBox(width: 12),
ChoiceChip(label: const Text('Public'), selected: !_privacy,
onSelected: (_) => setState(() => _privacy = false),
ChoiceChip(label: const Text('Public'), selected: _privacy == 'public',
onSelected: (_) => setState(() => _privacy = 'public'),
selectedColor: AppTheme.brightNavy.withValues(alpha: 0.15)),
const SizedBox(width: 8),
ChoiceChip(label: const Text('Private'), selected: _privacy,
onSelected: (_) => setState(() => _privacy = true),
ChoiceChip(label: const Text('Private'), selected: _privacy == 'private',
onSelected: (_) => setState(() => _privacy = 'private'),
selectedColor: AppTheme.brightNavy.withValues(alpha: 0.15)),
]),
const SizedBox(height: 14),
@ -2508,17 +2506,24 @@ class _CreateGroupInlineState extends ConsumerState<_CreateGroupInline> {
Wrap(
spacing: 6,
runSpacing: 6,
children: group_models.GroupCategory.values.map((cat) => ChoiceChip(
label: Text(cat.displayName),
children: GroupCategory.values.map((cat) => ChoiceChip(
label: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(cat.icon, size: 14, color: _category == cat ? SojornColors.basicWhite : cat.color),
const SizedBox(width: 4),
Text(cat.displayName),
],
),
selected: _category == cat,
onSelected: (_) => setState(() => _category = cat),
selectedColor: AppTheme.navyBlue,
selectedColor: cat.color,
labelStyle: TextStyle(
fontSize: 12, fontWeight: FontWeight.w600,
color: _category == cat ? Colors.white : Colors.black87,
color: _category == cat ? SojornColors.basicWhite : cat.color,
),
backgroundColor: AppTheme.navyBlue.withValues(alpha: 0.08),
side: BorderSide(color: AppTheme.navyBlue.withValues(alpha: 0.2)),
backgroundColor: cat.color.withValues(alpha: 0.08),
side: BorderSide(color: cat.color.withValues(alpha: 0.2)),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
showCheckmark: false,
visualDensity: VisualDensity.compact,

View file

@ -5,7 +5,6 @@ import '../../models/board_entry.dart';
import '../../services/api_service.dart';
import '../../theme/tokens.dart';
import '../../theme/app_theme.dart';
import '../../widgets/composer/composer_bar.dart';
class BoardEntryDetailScreen extends ConsumerStatefulWidget {
final BoardEntry entry;
@ -21,6 +20,8 @@ class _BoardEntryDetailScreenState extends ConsumerState<BoardEntryDetailScreen>
List<BoardReply> _replies = [];
bool _isLoading = true;
bool _isNeighborhoodAdmin = false;
bool _isSendingReply = false;
final _replyController = TextEditingController();
final _scrollController = ScrollController();
@override
@ -32,6 +33,7 @@ class _BoardEntryDetailScreenState extends ConsumerState<BoardEntryDetailScreen>
@override
void dispose() {
_replyController.dispose();
_scrollController.dispose();
super.dispose();
}
@ -55,32 +57,46 @@ class _BoardEntryDetailScreenState extends ConsumerState<BoardEntryDetailScreen>
}
}
Future<void> _sendReply(String text, String? _) async {
final data = await ApiService.instance.createBoardReply(
entryId: _entry.id,
body: text,
);
if (mounted) {
final reply = BoardReply.fromJson(data['reply'] as Map<String, dynamic>);
setState(() {
_replies.add(reply);
_entry = BoardEntry(
id: _entry.id, body: _entry.body, imageUrl: _entry.imageUrl, topic: _entry.topic,
lat: _entry.lat, long: _entry.long, upvotes: _entry.upvotes,
replyCount: _entry.replyCount + 1, isPinned: _entry.isPinned, createdAt: _entry.createdAt,
authorHandle: _entry.authorHandle, authorDisplayName: _entry.authorDisplayName,
authorAvatarUrl: _entry.authorAvatarUrl, hasVoted: _entry.hasVoted,
);
});
Future.delayed(const Duration(milliseconds: 100), () {
if (_scrollController.hasClients) {
_scrollController.animateTo(
_scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
Future<void> _sendReply() async {
final body = _replyController.text.trim();
if (body.isEmpty) return;
setState(() => _isSendingReply = true);
try {
final data = await ApiService.instance.createBoardReply(
entryId: _entry.id,
body: body,
);
if (mounted) {
final reply = BoardReply.fromJson(data['reply'] as Map<String, dynamic>);
setState(() {
_replies.add(reply);
_entry = BoardEntry(
id: _entry.id, body: _entry.body, imageUrl: _entry.imageUrl, topic: _entry.topic,
lat: _entry.lat, long: _entry.long, upvotes: _entry.upvotes,
replyCount: _entry.replyCount + 1, isPinned: _entry.isPinned, createdAt: _entry.createdAt,
authorHandle: _entry.authorHandle, authorDisplayName: _entry.authorDisplayName,
authorAvatarUrl: _entry.authorAvatarUrl, hasVoted: _entry.hasVoted,
);
}
});
_isSendingReply = false;
});
_replyController.clear();
// Scroll to bottom
Future.delayed(const Duration(milliseconds: 100), () {
if (_scrollController.hasClients) {
_scrollController.animateTo(
_scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
}
});
}
} catch (e) {
if (mounted) {
setState(() => _isSendingReply = false);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Could not post reply: $e')));
}
}
}
@ -396,9 +412,45 @@ class _BoardEntryDetailScreenState extends ConsumerState<BoardEntryDetailScreen>
color: AppTheme.cardSurface,
border: Border(top: BorderSide(color: AppTheme.navyBlue.withValues(alpha: 0.08))),
),
child: ComposerBar(
config: const ComposerConfig(hintText: 'Write a reply…'),
onSend: _sendReply,
child: Row(
children: [
Expanded(
child: TextField(
controller: _replyController,
style: TextStyle(color: SojornColors.postContent, fontSize: 14),
decoration: InputDecoration(
hintText: 'Write a reply…',
hintStyle: TextStyle(color: SojornColors.textDisabled, fontSize: 14),
filled: true,
fillColor: AppTheme.scaffoldBg,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(20), borderSide: BorderSide.none),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
isDense: true,
),
maxLines: 3,
minLines: 1,
textInputAction: TextInputAction.send,
onSubmitted: (_) => _sendReply(),
),
),
const SizedBox(width: 6),
GestureDetector(
onTap: _isSendingReply ? null : _sendReply,
child: Container(
width: 38, height: 38,
decoration: BoxDecoration(
color: AppTheme.brightNavy,
shape: BoxShape.circle,
),
child: _isSendingReply
? const Padding(
padding: EdgeInsets.all(10),
child: CircularProgressIndicator(strokeWidth: 2, color: SojornColors.basicWhite),
)
: const Icon(Icons.send, size: 16, color: SojornColors.basicWhite),
),
),
],
),
);
}

View file

@ -1,10 +1,12 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:image_picker/image_picker.dart';
import '../../models/board_entry.dart';
import '../../providers/api_provider.dart';
import '../../services/image_upload_service.dart';
import '../../theme/tokens.dart';
import '../../theme/app_theme.dart';
import '../../widgets/composer/composer_bar.dart';
/// Compose sheet for the standalone neighborhood board.
/// Creates board_entries completely separate from posts/beacons.
@ -25,23 +27,80 @@ class CreateBoardPostSheet extends ConsumerStatefulWidget {
}
class _CreateBoardPostSheetState extends ConsumerState<CreateBoardPostSheet> {
final ImageUploadService _imageUploadService = ImageUploadService();
final _bodyController = TextEditingController();
BoardTopic _selectedTopic = BoardTopic.community;
bool _isSubmitting = false;
bool _isUploadingImage = false;
File? _selectedImage;
String? _uploadedImageUrl;
static const _topics = BoardTopic.values;
Future<void> _onComposerSend(String text, String? imageUrl) async {
final apiService = ref.read(apiServiceProvider);
final data = await apiService.createBoardEntry(
body: text,
imageUrl: imageUrl,
topic: _selectedTopic.value,
lat: widget.centerLat,
long: widget.centerLong,
);
if (mounted) {
final entry = BoardEntry.fromJson(data['entry'] as Map<String, dynamic>);
widget.onEntryCreated(entry);
Navigator.of(context).pop();
@override
void dispose() {
_bodyController.dispose();
super.dispose();
}
Future<void> _pickImage() async {
setState(() => _isUploadingImage = true);
try {
final image = await ImagePicker().pickImage(
source: ImageSource.gallery,
maxWidth: 1920,
maxHeight: 1920,
imageQuality: 85,
);
if (image == null) {
setState(() => _isUploadingImage = false);
return;
}
final file = File(image.path);
setState(() => _selectedImage = file);
final imageUrl = await _imageUploadService.uploadImage(file);
if (mounted) setState(() { _uploadedImageUrl = imageUrl; _isUploadingImage = false; });
} catch (e) {
if (mounted) {
setState(() => _isUploadingImage = false);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Could not upload photo: $e')));
}
}
}
void _removeImage() {
setState(() { _selectedImage = null; _uploadedImageUrl = null; });
}
Future<void> _submit() async {
final body = _bodyController.text.trim();
if (body.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Write something to share with your neighbors.')));
return;
}
setState(() => _isSubmitting = true);
try {
final apiService = ref.read(apiServiceProvider);
final data = await apiService.createBoardEntry(
body: body,
imageUrl: _uploadedImageUrl,
topic: _selectedTopic.value,
lat: widget.centerLat,
long: widget.centerLong,
);
if (mounted) {
final entry = BoardEntry.fromJson(data['entry'] as Map<String, dynamic>);
widget.onEntryCreated(entry);
Navigator.of(context).pop();
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Could not create post: $e')));
}
} finally {
if (mounted) setState(() => _isSubmitting = false);
}
}
@ -77,7 +136,7 @@ class _CreateBoardPostSheetState extends ConsumerState<CreateBoardPostSheet> {
child: Text('Post to Board', style: TextStyle(color: AppTheme.navyBlue, fontSize: 18, fontWeight: FontWeight.bold)),
),
IconButton(
onPressed: () => Navigator.of(context).pop(),
onPressed: _isSubmitting ? null : () => Navigator.of(context).pop(),
icon: Icon(Icons.close, color: SojornColors.textDisabled),
),
],
@ -126,13 +185,93 @@ class _CreateBoardPostSheetState extends ConsumerState<CreateBoardPostSheet> {
),
const SizedBox(height: 16),
// Composer (text + photo + send)
ComposerBar(
config: const ComposerConfig(
allowImages: true,
// Body
TextFormField(
controller: _bodyController,
style: TextStyle(color: SojornColors.postContent, fontSize: 14),
decoration: InputDecoration(
hintText: 'Share with your neighborhood…',
hintStyle: TextStyle(color: SojornColors.textDisabled),
filled: true,
fillColor: AppTheme.scaffoldBg,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide(color: AppTheme.navyBlue.withValues(alpha: 0.1))),
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide(color: AppTheme.navyBlue.withValues(alpha: 0.1))),
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide(color: AppTheme.brightNavy, width: 1)),
counterStyle: TextStyle(color: SojornColors.textDisabled),
),
maxLines: 4,
maxLength: 500,
),
const SizedBox(height: 10),
// Photo
if (_selectedImage != null) ...[
Stack(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.file(_selectedImage!, height: 120, width: double.infinity, fit: BoxFit.cover),
),
Positioned(
top: 6, right: 6,
child: IconButton(
onPressed: _removeImage,
icon: const Icon(Icons.close, color: SojornColors.basicWhite, size: 18),
style: IconButton.styleFrom(backgroundColor: SojornColors.overlayDark, padding: const EdgeInsets.all(4)),
),
),
],
),
const SizedBox(height: 12),
] else ...[
GestureDetector(
onTap: _isUploadingImage ? null : _pickImage,
child: Container(
padding: const EdgeInsets.symmetric(vertical: 10),
decoration: BoxDecoration(
color: AppTheme.scaffoldBg,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: AppTheme.navyBlue.withValues(alpha: 0.1)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (_isUploadingImage)
SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2, color: AppTheme.brightNavy))
else
Icon(Icons.add_photo_alternate, size: 18, color: SojornColors.textDisabled),
const SizedBox(width: 8),
Text(_isUploadingImage ? 'Uploading…' : 'Add photo',
style: TextStyle(color: SojornColors.textDisabled, fontSize: 13)),
],
),
),
),
const SizedBox(height: 14),
],
// Submit
SizedBox(
width: double.infinity, height: 48,
child: ElevatedButton(
onPressed: _isSubmitting ? null : _submit,
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.brightNavy,
foregroundColor: SojornColors.basicWhite,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
disabledBackgroundColor: AppTheme.brightNavy.withValues(alpha: 0.3),
),
child: _isSubmitting
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2, color: SojornColors.basicWhite))
: const Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.send, size: 16),
SizedBox(width: 8),
Text('Post', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 15)),
],
),
),
onSend: _onComposerSend,
),
const SizedBox(height: 8),
],

View file

@ -1,863 +0,0 @@
import 'package:flutter/material.dart';
import 'package:latlong2/latlong.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:share_plus/share_plus.dart';
import '../../models/enhanced_beacon.dart';
import '../../theme/app_theme.dart';
class EnhancedBeaconDetailScreen extends StatefulWidget {
final EnhancedBeacon beacon;
const EnhancedBeaconDetailScreen({
super.key,
required this.beacon,
});
@override
State<EnhancedBeaconDetailScreen> createState() => _EnhancedBeaconDetailScreenState();
}
class _EnhancedBeaconDetailScreenState extends State<EnhancedBeaconDetailScreen> {
bool _isExpanded = false;
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
body: CustomScrollView(
slivers: [
// App bar with image
SliverAppBar(
expandedHeight: 250,
pinned: true,
backgroundColor: Colors.black,
flexibleSpace: FlexibleSpaceBar(
background: Stack(
fit: StackFit.expand,
children: [
// Map background
FlutterMap(
options: MapOptions(
initialCenter: LatLng(widget.beacon.lat, widget.beacon.lng),
initialZoom: 15.0,
interactionOptions: const InteractionOptions(flags: InteractiveFlag.none),
),
children: [
TileLayer(
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
userAgentPackageName: 'com.example.sojorn',
),
MarkerLayer(
markers: [
Marker(
point: LatLng(widget.beacon.lat, widget.beacon.lng),
width: 40,
height: 40,
child: Container(
decoration: BoxDecoration(
color: widget.beacon.category.color,
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 3),
boxShadow: [
BoxShadow(
color: widget.beacon.category.color.withOpacity(0.5),
blurRadius: 12,
spreadRadius: 3,
),
],
),
child: Icon(
widget.beacon.category.icon,
color: Colors.white,
size: 20,
),
),
),
],
),
],
),
// Gradient overlay
Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.transparent,
Colors.black.withOpacity(0.7),
Colors.black,
],
),
),
),
// Category badge
Positioned(
top: 60,
left: 16,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: widget.beacon.category.color,
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: widget.beacon.category.color.withOpacity(0.3),
blurRadius: 8,
spreadRadius: 2,
),
],
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
widget.beacon.category.icon,
color: Colors.white,
size: 16,
),
const SizedBox(width: 4),
Text(
widget.beacon.category.displayName,
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
],
),
),
),
],
),
),
),
// Content
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Title and status
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.beacon.title,
style: const TextStyle(
color: Colors.white,
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: widget.beacon.status.color,
borderRadius: BorderRadius.circular(12),
),
child: Text(
widget.beacon.status.displayName,
style: const TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(width: 8),
Text(
widget.beacon.timeAgo,
style: TextStyle(
color: Colors.grey[400],
fontSize: 12,
),
),
],
),
],
),
),
// Share button
IconButton(
onPressed: _shareBeacon,
icon: const Icon(Icons.share, color: Colors.white),
),
],
),
const SizedBox(height: 16),
// Author info
Row(
children: [
CircleAvatar(
radius: 20,
backgroundImage: widget.beacon.authorAvatar != null
? NetworkImage(widget.beacon.authorAvatar!)
: null,
child: widget.beacon.authorAvatar == null
? const Icon(Icons.person, color: Colors.white)
: null,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
widget.beacon.authorHandle,
style: const TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.bold,
),
),
if (widget.beacon.isVerified) ...[
const SizedBox(width: 4),
const Icon(
Icons.verified,
color: Colors.blue,
size: 16,
),
],
if (widget.beacon.isOfficialSource) ...[
const SizedBox(width: 4),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.blue,
borderRadius: BorderRadius.circular(8),
),
child: const Text(
'Official',
style: TextStyle(
color: Colors.white,
fontSize: 8,
fontWeight: FontWeight.bold,
),
),
),
],
],
),
if (widget.beacon.organizationName != null)
Text(
widget.beacon.organizationName!,
style: TextStyle(
color: Colors.grey[400],
fontSize: 12,
),
),
],
),
),
],
),
const SizedBox(height: 16),
// Description
Text(
widget.beacon.description,
style: const TextStyle(
color: Colors.white,
fontSize: 16,
height: 1.4,
),
),
// Image if available
if (widget.beacon.imageUrl != null) ...[
const SizedBox(height: 16),
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.network(
widget.beacon.imageUrl!,
height: 200,
width: double.infinity,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return Container(
height: 200,
color: Colors.grey[800],
child: const Center(
child: Icon(
Icons.image_not_supported,
color: Colors.grey,
size: 48,
),
),
);
},
),
),
],
const SizedBox(height: 20),
// Confidence score
_buildConfidenceSection(),
const SizedBox(height: 20),
// Engagement stats
_buildEngagementStats(),
const SizedBox(height: 20),
// Action items
if (widget.beacon.hasActionItems) ...[
_buildActionItems(),
const SizedBox(height: 20),
],
// How to help section
_buildHowToHelpSection(),
],
),
),
),
],
),
);
}
Widget _buildConfidenceSection() {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.grey[900],
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
widget.beacon.isHighConfidence ? Icons.check_circle : Icons.info,
color: widget.beacon.confidenceColor,
size: 20,
),
const SizedBox(width: 8),
Text(
widget.beacon.confidenceLabel,
style: TextStyle(
color: widget.beacon.confidenceColor,
fontSize: 14,
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 8),
LinearProgressIndicator(
value: widget.beacon.confidenceScore,
backgroundColor: Colors.grey[700],
valueColor: AlwaysStoppedAnimation<Color>(widget.beacon.confidenceColor),
),
const SizedBox(height: 4),
Text(
'Based on ${widget.beacon.vouchCount + widget.beacon.reportCount} community responses',
style: TextStyle(
color: Colors.grey[400],
fontSize: 12,
),
),
],
),
);
}
Widget _buildEngagementStats() {
return Row(
children: [
Expanded(
child: _buildStatCard(
'Vouches',
widget.beacon.vouchCount.toString(),
Icons.thumb_up,
Colors.green,
),
),
const SizedBox(width: 12),
Expanded(
child: _buildStatCard(
'Reports',
widget.beacon.reportCount.toString(),
Icons.flag,
Colors.red,
),
),
const SizedBox(width: 12),
Expanded(
child: _buildStatCard(
'Status',
widget.beacon.status.displayName,
Icons.info,
widget.beacon.status.color,
),
),
],
);
}
Widget _buildStatCard(String label, String value, IconData icon, Color color) {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.grey[900],
borderRadius: BorderRadius.circular(12),
),
child: Column(
children: [
Icon(icon, color: color, size: 20),
const SizedBox(height: 4),
Text(
value,
style: const TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
Text(
label,
style: TextStyle(
color: Colors.grey[400],
fontSize: 12,
),
),
],
),
);
}
Widget _buildActionItems() {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.grey[900],
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Action Items',
style: TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 12),
...widget.beacon.actionItems.asMap().entries.map((entry) {
final index = entry.key;
final action = entry.value;
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
children: [
Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: AppTheme.navyBlue,
shape: BoxShape.circle,
),
child: Center(
child: Text(
'${index + 1}',
style: TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
),
),
const SizedBox(width: 12),
Expanded(
child: Text(
action,
style: const TextStyle(
color: Colors.white,
fontSize: 14,
),
),
),
],
),
);
}).toList(),
],
),
);
}
Widget _buildHowToHelpSection() {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.grey[900],
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'How to Help',
style: TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 12),
// Help actions based on category
..._getHelpActions().map((action) => Padding(
padding: const EdgeInsets.only(bottom: 8),
child: _buildHelpAction(action),
)).toList(),
const SizedBox(height: 12),
// Contact info
if (widget.beacon.isOfficialSource)
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.blue.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.blue.withOpacity(0.3)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Official Contact Information',
style: TextStyle(
color: Colors.blue,
fontSize: 14,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
if (widget.beacon.organizationName != null)
Text(
widget.beacon.organizationName!,
style: const TextStyle(
color: Colors.white,
fontSize: 12,
),
),
const SizedBox(height: 4),
Text(
'This beacon is from an official source. Contact them directly for more information.',
style: TextStyle(
color: Colors.grey[400],
fontSize: 12,
),
),
],
),
),
],
),
);
}
List<HelpAction> _getHelpActions() {
switch (widget.beacon.category) {
case BeaconCategory.safetyAlert:
return [
HelpAction(
title: 'Report to Authorities',
description: 'Contact local emergency services if this is an active emergency',
icon: Icons.emergency,
color: Colors.red,
action: () => _callEmergency(),
),
HelpAction(
title: 'Share Information',
description: 'Help spread awareness by sharing this alert',
icon: Icons.share,
color: Colors.blue,
action: () => _shareBeacon(),
),
HelpAction(
title: 'Provide Updates',
description: 'If you have new information about this situation',
icon: Icons.update,
color: Colors.green,
action: () => _provideUpdate(),
),
];
case BeaconCategory.communityNeed:
return [
HelpAction(
title: 'Volunteer',
description: 'Offer your time and skills to help',
icon: Icons.volunteer_activism,
color: Colors.green,
action: () => _volunteer(),
),
HelpAction(
title: 'Donate Resources',
description: 'Contribute needed items or funds',
icon: Icons.card_giftcard,
color: Colors.orange,
action: () => _donate(),
),
HelpAction(
title: 'Spread the Word',
description: 'Help find more people who can assist',
icon: Icons.campaign,
color: Colors.blue,
action: () => _shareBeacon(),
),
];
case BeaconCategory.lostFound:
return [
HelpAction(
title: 'Report Sighting',
description: 'If you have seen this person/item',
icon: Icons.search,
color: Colors.blue,
action: () => _reportSighting(),
),
HelpAction(
title: 'Contact Owner',
description: 'Reach out with information you may have',
icon: Icons.phone,
color: Colors.green,
action: () => _contactOwner(),
),
HelpAction(
title: 'Keep Looking',
description: 'Join the search effort in your area',
icon: Icons.visibility,
color: Colors.orange,
action: () => _joinSearch(),
),
];
case BeaconCategory.event:
return [
HelpAction(
title: 'RSVP',
description: 'Let the organizer know you\'re attending',
icon: Icons.event_available,
color: Colors.green,
action: () => _rsvp(),
),
HelpAction(
title: 'Volunteer',
description: 'Help with event setup or coordination',
icon: Icons.people,
color: Colors.blue,
action: () => _volunteer(),
),
HelpAction(
title: 'Share Event',
description: 'Help promote this community event',
icon: Icons.share,
color: Colors.orange,
action: () => _shareBeacon(),
),
];
case BeaconCategory.mutualAid:
return [
HelpAction(
title: 'Offer Help',
description: 'Provide direct assistance if you\'re able',
icon: Icons.handshake,
color: Colors.green,
action: () => _offerHelp(),
),
HelpAction(
title: 'Share Resources',
description: 'Connect them with relevant services or people',
icon: Icons.share,
color: Colors.blue,
action: () => _shareResources(),
),
HelpAction(
title: 'Provide Support',
description: 'Offer emotional support or encouragement',
icon: Icons.favorite,
color: Colors.pink,
action: () => _provideSupport(),
),
];
}
}
Widget _buildHelpAction(HelpAction action) {
return GestureDetector(
onTap: action.action,
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.grey[800],
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: action.color,
shape: BoxShape.circle,
),
child: Icon(
action.icon,
color: Colors.white,
size: 16,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
action.title,
style: const TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 2),
Text(
action.description,
style: TextStyle(
color: Colors.grey[400],
fontSize: 12,
),
),
],
),
),
Icon(
Icons.arrow_forward_ios,
color: Colors.grey[400],
size: 16,
),
],
),
),
);
}
void _shareBeacon() {
Share.share(
'${widget.beacon.title}\n\n${widget.beacon.description}\n\nView on Sojorn',
subject: widget.beacon.title,
);
}
void _callEmergency() async {
final url = Uri.parse('tel:911');
if (await canLaunchUrl(url)) {
await launchUrl(url);
}
}
void _provideUpdate() {
// Navigate to comment/create post for this beacon
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Feature coming soon')),
);
}
void _volunteer() {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Volunteer feature coming soon')),
);
}
void _donate() {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Donation feature coming soon')),
);
}
void _reportSighting() {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Sighting report feature coming soon')),
);
}
void _contactOwner() {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Contact feature coming soon')),
);
}
void _joinSearch() {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Search coordination feature coming soon')),
);
}
void _rsvp() {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('RSVP feature coming soon')),
);
}
void _offerHelp() {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Direct help feature coming soon')),
);
}
void _shareResources() {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Resource sharing feature coming soon')),
);
}
void _provideSupport() {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Support feature coming soon')),
);
}
}
class HelpAction {
final String title;
final String description;
final IconData icon;
final Color color;
final VoidCallback action;
HelpAction({
required this.title,
required this.description,
required this.icon,
required this.color,
required this.action,
});
}

View file

@ -2,19 +2,14 @@ import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../models/cluster.dart';
import '../../models/group.dart' as group_models;
import '../../providers/api_provider.dart';
import '../../services/api_service.dart';
import '../../services/capsule_security_service.dart';
import '../../theme/tokens.dart';
import '../../theme/app_theme.dart';
import 'group_screen.dart';
import '../../widgets/skeleton_loader.dart';
import '../../widgets/group_card.dart';
import '../../widgets/group_creation_modal.dart';
/// ClustersScreen Discovery-first groups page.
/// Shows "Your Groups" at top, then "Discover Communities" with category filtering.
/// ClustersScreen Discovery and listing of all clusters the user belongs to.
/// Split into two sections: Public Clusters (geo) and Private Capsules (E2EE).
class ClustersScreen extends ConsumerStatefulWidget {
const ClustersScreen({super.key});
@ -26,35 +21,15 @@ class _ClustersScreenState extends ConsumerState<ClustersScreen>
with SingleTickerProviderStateMixin {
late TabController _tabController;
bool _isLoading = true;
bool _isDiscoverLoading = false;
List<Cluster> _myGroups = [];
List<Cluster> _myCapsules = [];
List<Map<String, dynamic>> _discoverGroups = [];
List<Cluster> _publicClusters = [];
List<Cluster> _privateCapsules = [];
Map<String, String> _encryptedKeys = {};
String _selectedCategory = 'all';
// Groups system state
List<group_models.Group> _myUserGroups = [];
List<group_models.SuggestedGroup> _suggestedGroups = [];
bool _isGroupsLoading = false;
bool _isSuggestedLoading = false;
static const _categories = [
('all', 'All', Icons.grid_view),
('general', 'General', Icons.chat_bubble_outline),
('hobby', 'Hobby', Icons.palette),
('sports', 'Sports', Icons.sports),
('professional', 'Professional', Icons.business_center),
('local_business', 'Local', Icons.storefront),
('support', 'Support', Icons.favorite),
('education', 'Education', Icons.school),
];
@override
void initState() {
super.initState();
_tabController = TabController(length: 2, vsync: this);
_loadAll();
_loadClusters();
}
@override
@ -63,65 +38,27 @@ class _ClustersScreenState extends ConsumerState<ClustersScreen>
super.dispose();
}
Future<void> _loadAll() async {
Future<void> _loadClusters() async {
setState(() => _isLoading = true);
await Future.wait([
_loadMyGroups(),
_loadDiscover(),
_loadUserGroups(),
_loadSuggestedGroups(),
]);
if (mounted) setState(() => _isLoading = false);
}
Future<void> _loadMyGroups() async {
try {
final groups = await ApiService.instance.fetchMyGroups();
final allClusters = groups.map((g) => Cluster.fromJson(g)).toList();
if (mounted) {
setState(() {
_myGroups = allClusters.where((c) => !c.isCapsule).toList();
_myCapsules = allClusters.where((c) => c.isCapsule).toList();
_publicClusters = allClusters.where((c) => !c.isCapsule).toList();
_privateCapsules = allClusters.where((c) => c.isCapsule).toList();
// Store encrypted keys for quick access when navigating
_encryptedKeys = {
for (final g in groups)
if ((g['encrypted_group_key'] as String?)?.isNotEmpty == true)
g['id'] as String: g['encrypted_group_key'] as String,
};
_isLoading = false;
});
}
} catch (e) {
if (kDebugMode) print('[Clusters] Load error: $e');
}
}
Future<void> _loadDiscover() async {
setState(() => _isDiscoverLoading = true);
try {
final groups = await ApiService.instance.discoverGroups(
category: _selectedCategory == 'all' ? null : _selectedCategory,
);
if (mounted) setState(() => _discoverGroups = groups);
} catch (e) {
if (kDebugMode) print('[Clusters] Discover error: $e');
}
if (mounted) setState(() => _isDiscoverLoading = false);
}
Future<void> _joinGroup(String groupId) async {
try {
await ApiService.instance.joinGroup(groupId);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Joined group!'), backgroundColor: Color(0xFF4CAF50)),
);
}
await _loadAll();
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('$e'), backgroundColor: Colors.red),
);
}
if (mounted) setState(() => _isLoading = false);
}
}
@ -134,55 +71,12 @@ class _ClustersScreenState extends ConsumerState<ClustersScreen>
));
}
// Groups system methods
Future<void> _loadUserGroups() async {
setState(() => _isGroupsLoading = true);
try {
final api = ref.read(apiServiceProvider);
final groups = await api.getMyGroups();
if (mounted) setState(() => _myUserGroups = groups);
} catch (e) {
if (kDebugMode) print('[Groups] Load user groups error: $e');
}
if (mounted) setState(() => _isGroupsLoading = false);
}
Future<void> _loadSuggestedGroups() async {
setState(() => _isSuggestedLoading = true);
try {
final api = ref.read(apiServiceProvider);
final suggestions = await api.getSuggestedGroups();
if (mounted) setState(() => _suggestedGroups = suggestions);
} catch (e) {
if (kDebugMode) print('[Groups] Load suggestions error: $e');
}
if (mounted) setState(() => _isSuggestedLoading = false);
}
void _navigateToGroup(group_models.Group group) {
final cluster = Cluster(
id: group.id,
name: group.name,
description: group.description,
type: group.isPrivate ? 'private_capsule' : 'geo',
privacy: group.isPrivate ? 'private' : 'public',
avatarUrl: group.avatarUrl,
memberCount: group.memberCount,
isEncrypted: false,
category: group.category,
createdAt: group.createdAt,
);
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => GroupScreen(group: cluster)),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppTheme.scaffoldBg,
appBar: AppBar(
title: const Text('Communities', style: TextStyle(fontWeight: FontWeight.w800)),
title: const Text('Groups', style: TextStyle(fontWeight: FontWeight.w800)),
backgroundColor: AppTheme.scaffoldBg,
surfaceTintColor: SojornColors.transparent,
bottom: TabBar(
@ -206,133 +100,38 @@ class _ClustersScreenState extends ConsumerState<ClustersScreen>
body: TabBarView(
controller: _tabController,
children: [
_buildGroupsTab(),
_buildPublicTab(),
_buildCapsuleTab(),
],
),
);
}
// Groups Tab (Your Groups + Discover)
Widget _buildGroupsTab() {
if (_isLoading) return const SingleChildScrollView(child: SkeletonGroupList(count: 6));
Widget _buildPublicTab() {
if (_isLoading) return const Center(child: CircularProgressIndicator());
if (_publicClusters.isEmpty) return _EmptyState(
icon: Icons.location_on,
title: 'No Neighborhoods Yet',
subtitle: 'Public clusters based on your location will appear here.',
actionLabel: 'Discover Nearby',
onAction: _loadClusters,
);
return RefreshIndicator(
onRefresh: _loadAll,
child: ListView(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
children: [
// Your Groups
if (_myUserGroups.isNotEmpty) ...[
_SectionHeader(title: 'Your Groups', count: _myUserGroups.length),
const SizedBox(height: 8),
SizedBox(
height: 180,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: _myUserGroups.length,
separatorBuilder: (_, __) => const SizedBox(width: 12),
itemBuilder: (_, i) {
final group = _myUserGroups[i];
return CompactGroupCard(
group: group,
onTap: () => _navigateToGroup(group),
);
},
),
),
const SizedBox(height: 20),
],
// Discover Communities
_SectionHeader(title: 'Discover Communities'),
const SizedBox(height: 10),
// Category chips (horizontal scroll)
SizedBox(
height: 36,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: _categories.length,
separatorBuilder: (_, __) => const SizedBox(width: 8),
itemBuilder: (_, i) {
final (value, label, icon) = _categories[i];
final selected = _selectedCategory == value;
return FilterChip(
label: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 14, color: selected ? Colors.white : AppTheme.navyBlue),
const SizedBox(width: 5),
Text(label, style: TextStyle(
fontSize: 12, fontWeight: FontWeight.w600,
color: selected ? Colors.white : AppTheme.navyBlue,
)),
],
),
selected: selected,
onSelected: (_) {
setState(() => _selectedCategory = value);
_loadSuggestedGroups();
},
selectedColor: AppTheme.navyBlue,
backgroundColor: AppTheme.navyBlue.withValues(alpha: 0.06),
side: BorderSide(color: selected ? AppTheme.navyBlue : AppTheme.navyBlue.withValues(alpha: 0.15)),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
showCheckmark: false,
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 0),
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
visualDensity: VisualDensity.compact,
);
},
),
),
const SizedBox(height: 12),
// Discover results
if (_isSuggestedLoading)
const SkeletonGroupList(count: 4)
else if (_suggestedGroups.isEmpty)
_EmptyDiscoverState(
onCreateGroup: () => _showCreateSheet(context),
)
else
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 8),
..._suggestedGroups.map((suggested) {
return GroupCard(
group: suggested.group,
onTap: () => _navigateToGroup(suggested.group),
showReason: true,
reason: suggested.reason,
);
}),
const SizedBox(height: 20),
],
),
// Create group CTA at bottom
const SizedBox(height: 16),
Center(
child: TextButton.icon(
onPressed: () => _showCreateSheet(context),
icon: Icon(Icons.add_circle_outline, size: 18, color: AppTheme.navyBlue),
label: Text('Create a Group', style: TextStyle(
color: AppTheme.navyBlue, fontWeight: FontWeight.w600,
)),
),
),
const SizedBox(height: 32),
],
onRefresh: _loadClusters,
child: ListView.builder(
padding: const EdgeInsets.all(12),
itemCount: _publicClusters.length,
itemBuilder: (_, i) => _PublicClusterCard(
cluster: _publicClusters[i],
onTap: () => _navigateToCluster(_publicClusters[i]),
),
),
);
}
// Capsules Tab
Widget _buildCapsuleTab() {
if (_isLoading) return const SingleChildScrollView(child: SkeletonGroupList(count: 4));
if (_myCapsules.isEmpty) return _EmptyState(
if (_isLoading) return const Center(child: CircularProgressIndicator());
if (_privateCapsules.isEmpty) return _EmptyState(
icon: Icons.lock,
title: 'No Capsules Yet',
subtitle: 'Create an encrypted capsule or join one via invite code.',
@ -340,108 +139,31 @@ class _ClustersScreenState extends ConsumerState<ClustersScreen>
onAction: () => _showCreateSheet(context, capsule: true),
);
return RefreshIndicator(
onRefresh: _loadAll,
onRefresh: _loadClusters,
child: ListView.builder(
padding: const EdgeInsets.all(12),
itemCount: _myCapsules.length,
itemCount: _privateCapsules.length,
itemBuilder: (_, i) => _CapsuleCard(
capsule: _myCapsules[i],
onTap: () => _navigateToCluster(_myCapsules[i]),
capsule: _privateCapsules[i],
onTap: () => _navigateToCluster(_privateCapsules[i]),
),
),
);
}
void _showCreateSheet(BuildContext context, {bool capsule = false}) {
if (capsule) {
// Keep existing capsule creation
showModalBottomSheet(
context: context,
backgroundColor: AppTheme.cardSurface,
isScrollControlled: true,
builder: (ctx) => _CreateCapsuleForm(onCreated: () { Navigator.pop(ctx); _loadAll(); }),
);
} else {
// Use new GroupCreationModal
showDialog(
context: context,
builder: (ctx) => GroupCreationModal(),
).then((_) {
// Refresh data after modal is closed
_loadAll();
});
}
}
}
// Section Header
class _SectionHeader extends StatelessWidget {
final String title;
final int? count;
const _SectionHeader({required this.title, this.count});
@override
Widget build(BuildContext context) {
return Row(
children: [
Text(title, style: TextStyle(
fontSize: 16, fontWeight: FontWeight.w700, color: AppTheme.navyBlue,
)),
if (count != null) ...[
const SizedBox(width: 6),
Container(
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 1),
decoration: BoxDecoration(
color: AppTheme.navyBlue.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(10),
),
child: Text('$count', style: TextStyle(
fontSize: 11, fontWeight: FontWeight.w700, color: AppTheme.navyBlue,
)),
),
],
],
showModalBottomSheet(
context: context,
backgroundColor: AppTheme.cardSurface,
isScrollControlled: true,
builder: (ctx) => capsule
? _CreateCapsuleForm(onCreated: () { Navigator.pop(ctx); _loadClusters(); })
: _CreateGroupForm(onCreated: () { Navigator.pop(ctx); _loadClusters(); }),
);
}
}
// Empty Discover State
class _EmptyDiscoverState extends StatelessWidget {
final VoidCallback onCreateGroup;
const _EmptyDiscoverState({required this.onCreateGroup});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 32),
child: Column(
children: [
Icon(Icons.explore_outlined, size: 48, color: AppTheme.navyBlue.withValues(alpha: 0.2)),
const SizedBox(height: 12),
Text('No groups found in this category', style: TextStyle(
fontSize: 14, fontWeight: FontWeight.w600,
color: AppTheme.navyBlue.withValues(alpha: 0.5),
)),
const SizedBox(height: 4),
Text('Be the first to create one!', style: TextStyle(
fontSize: 12, color: AppTheme.navyBlue.withValues(alpha: 0.35),
)),
const SizedBox(height: 16),
OutlinedButton.icon(
onPressed: onCreateGroup,
icon: const Icon(Icons.add, size: 16),
label: const Text('Create Group'),
style: OutlinedButton.styleFrom(
side: BorderSide(color: AppTheme.navyBlue.withValues(alpha: 0.3)),
),
),
],
),
);
}
}
// Empty State (for capsules)
// Empty State
class _EmptyState extends StatelessWidget {
final IconData icon;
final String title;
@ -490,15 +212,14 @@ class _EmptyState extends StatelessWidget {
}
}
// Group Card (user's own groups) ───────────────────────────────────────
class _GroupCard extends StatelessWidget {
// Public Cluster Card
class _PublicClusterCard extends StatelessWidget {
final Cluster cluster;
final VoidCallback onTap;
const _GroupCard({required this.cluster, required this.onTap});
const _PublicClusterCard({required this.cluster, required this.onTap});
@override
Widget build(BuildContext context) {
final cat = cluster.category;
return GestureDetector(
onTap: onTap,
child: Container(
@ -518,13 +239,14 @@ class _GroupCard extends StatelessWidget {
),
child: Row(
children: [
// Avatar / location icon
Container(
width: 48, height: 48,
decoration: BoxDecoration(
color: cat.color.withValues(alpha: 0.1),
color: AppTheme.brightNavy.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(14),
),
child: Icon(cat.icon, color: cat.color, size: 24),
child: Icon(Icons.location_on, color: AppTheme.brightNavy, size: 24),
),
const SizedBox(width: 14),
Expanded(
@ -537,9 +259,13 @@ class _GroupCard extends StatelessWidget {
const SizedBox(height: 3),
Row(
children: [
Icon(Icons.public, size: 12, color: SojornColors.textDisabled),
const SizedBox(width: 4),
Text('Public', style: TextStyle(fontSize: 11, color: SojornColors.textDisabled)),
const SizedBox(width: 10),
Icon(Icons.people, size: 12, color: SojornColors.textDisabled),
const SizedBox(width: 4),
Text('${cluster.memberCount} members', style: TextStyle(fontSize: 11, color: SojornColors.textDisabled)),
Text('${cluster.memberCount}', style: TextStyle(fontSize: 11, color: SojornColors.textDisabled)),
],
),
],
@ -553,125 +279,6 @@ class _GroupCard extends StatelessWidget {
}
}
// Discover Group Card (with Join button)
class _DiscoverGroupCard extends StatelessWidget {
final String name;
final String description;
final int memberCount;
final GroupCategory category;
final bool isMember;
final VoidCallback? onJoin;
final VoidCallback? onTap;
const _DiscoverGroupCard({
required this.name,
required this.description,
required this.memberCount,
required this.category,
required this.isMember,
this.onJoin,
this.onTap,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
margin: const EdgeInsets.only(bottom: 10),
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: AppTheme.cardSurface,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppTheme.navyBlue.withValues(alpha: 0.08)),
boxShadow: [
BoxShadow(
color: AppTheme.brightNavy.withValues(alpha: 0.04),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Row(
children: [
Container(
width: 44, height: 44,
decoration: BoxDecoration(
color: category.color.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
),
child: Icon(category.icon, color: category.color, size: 22),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(name, style: const TextStyle(
fontSize: 14, fontWeight: FontWeight.w600,
), maxLines: 1, overflow: TextOverflow.ellipsis),
if (description.isNotEmpty) ...[
const SizedBox(height: 2),
Text(description, style: TextStyle(
fontSize: 12, color: SojornColors.textDisabled,
), maxLines: 1, overflow: TextOverflow.ellipsis),
],
const SizedBox(height: 3),
Row(
children: [
Icon(Icons.people_outline, size: 12, color: SojornColors.textDisabled),
const SizedBox(width: 3),
Text('$memberCount', style: TextStyle(fontSize: 11, color: SojornColors.textDisabled)),
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
decoration: BoxDecoration(
color: category.color.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(6),
),
child: Text(category.displayName, style: TextStyle(
fontSize: 10, fontWeight: FontWeight.w600, color: category.color,
)),
),
],
),
],
),
),
const SizedBox(width: 8),
if (isMember)
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: const Color(0xFF4CAF50).withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(20),
),
child: const Text('Joined', style: TextStyle(
fontSize: 12, fontWeight: FontWeight.w600, color: Color(0xFF4CAF50),
)),
)
else
SizedBox(
height: 32,
child: ElevatedButton(
onPressed: onJoin,
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.navyBlue,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
elevation: 0,
),
child: const Text('Join', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w700)),
),
),
],
),
),
);
}
}
// Private Capsule Card
class _CapsuleCard extends StatelessWidget {
final Cluster capsule;
@ -699,6 +306,7 @@ class _CapsuleCard extends StatelessWidget {
),
child: Row(
children: [
// Lock avatar
Container(
width: 48, height: 48,
decoration: BoxDecoration(
@ -743,18 +351,18 @@ class _CapsuleCard extends StatelessWidget {
}
// Create Group Form (non-encrypted, public/private)
class _CreateGroupForm extends ConsumerStatefulWidget {
class _CreateGroupForm extends StatefulWidget {
final VoidCallback onCreated;
const _CreateGroupForm({required this.onCreated});
@override
ConsumerState<_CreateGroupForm> createState() => _CreateGroupFormState();
State<_CreateGroupForm> createState() => _CreateGroupFormState();
}
class _CreateGroupFormState extends ConsumerState<_CreateGroupForm> {
class _CreateGroupFormState extends State<_CreateGroupForm> {
final _nameCtrl = TextEditingController();
final _descCtrl = TextEditingController();
bool _privacy = false;
String _privacy = 'public';
bool _submitting = false;
@override
@ -764,12 +372,10 @@ class _CreateGroupFormState extends ConsumerState<_CreateGroupForm> {
if (_nameCtrl.text.trim().isEmpty) return;
setState(() => _submitting = true);
try {
final api = ref.read(apiServiceProvider);
await api.createGroup(
await ApiService.instance.createGroup(
name: _nameCtrl.text.trim(),
description: _descCtrl.text.trim(),
category: GroupCategory.general,
isPrivate: _privacy,
privacy: _privacy,
);
widget.onCreated();
} catch (e) {
@ -826,15 +432,15 @@ class _CreateGroupFormState extends ConsumerState<_CreateGroupForm> {
const SizedBox(width: 12),
ChoiceChip(
label: const Text('Public'),
selected: !_privacy,
onSelected: (_) => setState(() => _privacy = false),
selected: _privacy == 'public',
onSelected: (_) => setState(() => _privacy = 'public'),
selectedColor: AppTheme.brightNavy.withValues(alpha: 0.15),
),
const SizedBox(width: 8),
ChoiceChip(
label: const Text('Private'),
selected: _privacy,
onSelected: (_) => setState(() => _privacy = true),
selected: _privacy == 'private',
onSelected: (_) => setState(() => _privacy = 'private'),
selectedColor: AppTheme.brightNavy.withValues(alpha: 0.15),
),
],

View file

@ -1,13 +1,10 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:cryptography/cryptography.dart';
import '../../config/api_config.dart';
import '../../services/api_service.dart';
import '../../services/capsule_security_service.dart';
import '../../services/content_guard_service.dart';
import '../../theme/tokens.dart';
import '../../theme/app_theme.dart';
import '../../widgets/composer/composer_bar.dart';
class GroupChatTab extends StatefulWidget {
final String groupId;
@ -28,9 +25,11 @@ class GroupChatTab extends StatefulWidget {
}
class _GroupChatTabState extends State<GroupChatTab> {
final TextEditingController _msgCtrl = TextEditingController();
final ScrollController _scrollCtrl = ScrollController();
List<Map<String, dynamic>> _messages = [];
bool _loading = true;
bool _sending = false;
@override
void initState() {
@ -40,6 +39,7 @@ class _GroupChatTabState extends State<GroupChatTab> {
@override
void dispose() {
_msgCtrl.dispose();
_scrollCtrl.dispose();
super.dispose();
}
@ -51,16 +51,7 @@ class _GroupChatTabState extends State<GroupChatTab> {
await _loadEncryptedMessages();
} else {
final msgs = await ApiService.instance.fetchGroupMessages(widget.groupId);
// Detect GIF URLs stored as body text (from sendGroupMessage fallback)
_messages = msgs.reversed.map((msg) {
final body = msg['body'] as String? ?? '';
if (msg['gif_url'] == null && body.isNotEmpty && ApiConfig.needsProxy(body)) {
return Map<String, dynamic>.from(msg)
..['gif_url'] = body
..['body'] = '';
}
return msg;
}).toList();
_messages = msgs.reversed.toList(); // API returns newest first, we want oldest first
}
} catch (e) {
debugPrint('[GroupChat] Error: $e');
@ -93,7 +84,6 @@ class _GroupChatTabState extends State<GroupChatTab> {
'author_avatar_url': entry['author_avatar_url'] ?? '',
'created_at': entry['created_at'],
'body': payload['text'] ?? '',
'gif_url': payload['gif_url'],
});
} catch (_) {
decrypted.add({
@ -108,150 +98,60 @@ class _GroupChatTabState extends State<GroupChatTab> {
_messages = decrypted.reversed.toList();
}
Future<void> _onChatSend(String text, String? gifUrl) async {
if (text.isNotEmpty) {
// Local content guard block before encryption
final guardReason = ContentGuardService.instance.check(text);
if (guardReason != null) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(guardReason), backgroundColor: Colors.red),
);
}
throw Exception('blocked'); // prevents ComposerBar from clearing
}
Future<void> _sendMessage() async {
final text = _msgCtrl.text.trim();
if (text.isEmpty || _sending) return;
// Server-side AI moderation stateless, nothing stored
final aiReason = await ApiService.instance.moderateContent(text: text, context: 'group');
if (aiReason != null) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(aiReason), backgroundColor: Colors.red),
);
}
throw Exception('blocked');
// Local content guard block before encryption
final guardReason = ContentGuardService.instance.check(text);
if (guardReason != null) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(guardReason), backgroundColor: Colors.red),
);
}
return;
}
final payload = {
'text': text,
'ts': DateTime.now().toIso8601String(),
if (gifUrl != null) 'gif_url': gifUrl,
};
if (widget.isEncrypted && widget.capsuleKey != null) {
final encrypted = await CapsuleSecurityService.encryptPayload(
payload: payload,
capsuleKey: widget.capsuleKey!,
);
await ApiService.instance.callGoApi(
'/capsules/${widget.groupId}/entries',
method: 'POST',
body: {
'iv': encrypted.iv,
'encrypted_payload': encrypted.encryptedPayload,
'data_type': 'chat',
'key_version': 1,
},
);
} else {
await ApiService.instance.sendGroupMessage(
widget.groupId, body: text.isNotEmpty ? text : gifUrl ?? '');
// Server-side AI moderation stateless, nothing stored
final aiReason = await ApiService.instance.moderateContent(text: text, context: 'group');
if (aiReason != null) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(aiReason), backgroundColor: Colors.red),
);
}
return;
}
if (mounted) await _loadMessages();
}
void _reportMessage(Map<String, dynamic> msg) {
final entryId = msg['id']?.toString() ?? '';
final body = msg['body'] as String? ?? '';
if (entryId.isEmpty) return;
String? selectedReason;
const reasons = ['Harassment', 'Hate speech', 'Threats', 'Spam', 'Illegal content', 'Other'];
showModalBottomSheet(
context: context,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (ctx) => StatefulBuilder(
builder: (ctx, setBS) => Padding(
padding: EdgeInsets.only(
left: 20, right: 20, top: 20,
bottom: MediaQuery.of(ctx).viewInsets.bottom + 24,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Report Message',
style: TextStyle(
color: AppTheme.navyBlue,
fontSize: 16,
fontWeight: FontWeight.w700)),
const SizedBox(height: 4),
Text('Why are you reporting this message?',
style: TextStyle(color: SojornColors.textDisabled, fontSize: 13)),
const SizedBox(height: 16),
...reasons.map((r) => RadioListTile<String>(
dense: true,
title: Text(r,
style: TextStyle(color: SojornColors.postContent, fontSize: 14)),
value: r,
groupValue: selectedReason,
activeColor: AppTheme.brightNavy,
onChanged: (v) => setBS(() => selectedReason = v),
)),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: FilledButton(
style: FilledButton.styleFrom(
backgroundColor: AppTheme.brightNavy,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12)),
),
onPressed: selectedReason == null
? null
: () async {
Navigator.of(ctx).pop();
await _submitReport(entryId, selectedReason!, body);
},
child: const Text('Submit Report'),
),
),
],
),
),
),
);
}
Future<void> _submitReport(String entryId, String reason, String sample) async {
setState(() => _sending = true);
try {
await ApiService.instance.callGoApi(
'/capsules/${widget.groupId}/entries/$entryId/report',
method: 'POST',
body: {
'reason': reason,
if (sample.isNotEmpty && sample != '[Decryption failed]')
'decrypted_sample': sample,
},
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Report submitted. Thank you.')),
if (widget.isEncrypted && widget.capsuleKey != null) {
final encrypted = await CapsuleSecurityService.encryptPayload(
payload: {'text': text, 'ts': DateTime.now().toIso8601String()},
capsuleKey: widget.capsuleKey!,
);
await ApiService.instance.callGoApi(
'/capsules/${widget.groupId}/entries',
method: 'POST',
body: {
'iv': encrypted.iv,
'encrypted_payload': encrypted.encryptedPayload,
'data_type': 'chat',
'key_version': 1,
},
);
} else {
await ApiService.instance.sendGroupMessage(widget.groupId, body: text);
}
} catch (_) {
_msgCtrl.clear();
await _loadMessages();
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Could not submit report. Please try again.')),
);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Failed: $e')));
}
}
if (mounted) setState(() => _sending = false);
}
void _scrollToBottom() {
@ -313,10 +213,6 @@ class _GroupChatTabState extends State<GroupChatTab> {
isMine: isMine,
isEncrypted: widget.isEncrypted,
timeStr: _timeStr(msg['created_at']?.toString()),
gifUrl: msg['gif_url'] as String?,
onReport: (!isMine && widget.isEncrypted)
? () => _reportMessage(msg)
: null,
);
},
),
@ -324,18 +220,46 @@ class _GroupChatTabState extends State<GroupChatTab> {
),
// Compose bar
Container(
padding: const EdgeInsets.fromLTRB(12, 6, 8, 12),
padding: const EdgeInsets.fromLTRB(12, 8, 8, 12),
decoration: BoxDecoration(
color: AppTheme.cardSurface,
border: Border(top: BorderSide(color: AppTheme.navyBlue.withValues(alpha: 0.08))),
),
child: SafeArea(
top: false,
child: ComposerBar(
config: widget.isEncrypted
? const ComposerConfig(allowGifs: true, hintText: 'Encrypted message…')
: ComposerConfig.chat,
onSend: _onChatSend,
child: Row(
children: [
Expanded(
child: TextField(
controller: _msgCtrl,
style: TextStyle(color: SojornColors.postContent, fontSize: 14),
decoration: InputDecoration(
hintText: widget.isEncrypted ? 'Encrypted message…' : 'Type a message…',
hintStyle: TextStyle(color: SojornColors.textDisabled),
filled: true,
fillColor: AppTheme.scaffoldBg,
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(20), borderSide: BorderSide.none),
),
textInputAction: TextInputAction.send,
onSubmitted: (_) => _sendMessage(),
),
),
const SizedBox(width: 8),
GestureDetector(
onTap: _sendMessage,
child: Container(
width: 40, height: 40,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: _sending ? AppTheme.brightNavy.withValues(alpha: 0.5) : AppTheme.brightNavy,
),
child: _sending
? const Padding(padding: EdgeInsets.all(10), child: CircularProgressIndicator(strokeWidth: 2, color: SojornColors.basicWhite))
: const Icon(Icons.send, color: SojornColors.basicWhite, size: 18),
),
),
],
),
),
),
@ -350,16 +274,12 @@ class _ChatBubble extends StatelessWidget {
final bool isMine;
final bool isEncrypted;
final String timeStr;
final String? gifUrl;
final VoidCallback? onReport;
const _ChatBubble({
required this.message,
required this.isMine,
required this.isEncrypted,
required this.timeStr,
this.gifUrl,
this.onReport,
});
@override
@ -370,9 +290,7 @@ class _ChatBubble extends StatelessWidget {
return Align(
alignment: isMine ? Alignment.centerRight : Alignment.centerLeft,
child: GestureDetector(
onLongPress: onReport,
child: Container(
child: Container(
margin: const EdgeInsets.symmetric(vertical: 3),
constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.78),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
@ -416,30 +334,9 @@ class _ChatBubble extends StatelessWidget {
],
),
),
if (body.isNotEmpty)
Text(body, style: TextStyle(color: SojornColors.postContent, fontSize: 14, height: 1.35)),
if (gifUrl != null) ...[
if (body.isNotEmpty) const SizedBox(height: 6),
ClipRRect(
borderRadius: BorderRadius.circular(10),
child: CachedNetworkImage(
imageUrl: ApiConfig.needsProxy(gifUrl!)
? ApiConfig.proxyImageUrl(gifUrl!)
: gifUrl!,
width: 200,
fit: BoxFit.cover,
placeholder: (_, __) => Container(
width: 200, height: 120,
color: AppTheme.navyBlue.withValues(alpha: 0.05),
child: Icon(Icons.gif_outlined, color: AppTheme.textSecondary, size: 32),
),
errorWidget: (_, __, ___) => const SizedBox.shrink(),
),
),
],
Text(body, style: TextStyle(color: SojornColors.postContent, fontSize: 14, height: 1.35)),
],
),
),
),
);
}

View file

@ -1,11 +1,9 @@
import 'package:flutter/material.dart';
import 'package:cryptography/cryptography.dart';
import '../../config/api_config.dart';
import '../../services/api_service.dart';
import '../../services/capsule_security_service.dart';
import '../../theme/tokens.dart';
import '../../theme/app_theme.dart';
import '../../widgets/composer/composer_bar.dart';
class GroupFeedTab extends StatefulWidget {
final String groupId;
@ -26,8 +24,10 @@ class GroupFeedTab extends StatefulWidget {
}
class _GroupFeedTabState extends State<GroupFeedTab> {
final TextEditingController _postCtrl = TextEditingController();
List<Map<String, dynamic>> _posts = [];
bool _loading = true;
bool _posting = false;
@override
void initState() {
@ -37,6 +37,7 @@ class _GroupFeedTabState extends State<GroupFeedTab> {
@override
void dispose() {
_postCtrl.dispose();
super.dispose();
}
@ -96,31 +97,37 @@ class _GroupFeedTabState extends State<GroupFeedTab> {
_posts = decrypted;
}
Future<void> _onComposerSend(String text, String? mediaUrl) async {
if (widget.isEncrypted && widget.capsuleKey != null) {
final encrypted = await CapsuleSecurityService.encryptPayload(
payload: {'text': text, 'ts': DateTime.now().toIso8601String(),
if (mediaUrl != null) 'image_url': mediaUrl},
capsuleKey: widget.capsuleKey!,
);
await ApiService.instance.callGoApi(
'/capsules/${widget.groupId}/entries',
method: 'POST',
body: {
'iv': encrypted.iv,
'encrypted_payload': encrypted.encryptedPayload,
'data_type': 'post',
'key_version': 1,
},
);
} else {
await ApiService.instance.createGroupPost(
widget.groupId,
body: text,
imageUrl: mediaUrl,
);
Future<void> _createPost() async {
final text = _postCtrl.text.trim();
if (text.isEmpty || _posting) return;
setState(() => _posting = true);
try {
if (widget.isEncrypted && widget.capsuleKey != null) {
final encrypted = await CapsuleSecurityService.encryptPayload(
payload: {'text': text, 'ts': DateTime.now().toIso8601String()},
capsuleKey: widget.capsuleKey!,
);
await ApiService.instance.callGoApi(
'/capsules/${widget.groupId}/entries',
method: 'POST',
body: {
'iv': encrypted.iv,
'encrypted_payload': encrypted.encryptedPayload,
'data_type': 'post',
'key_version': 1,
},
);
} else {
await ApiService.instance.createGroupPost(widget.groupId, body: text);
}
_postCtrl.clear();
await _loadPosts();
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Failed to post: $e')));
}
}
if (mounted) await _loadPosts();
if (mounted) setState(() => _posting = false);
}
Future<void> _toggleLike(String postId, int index) async {
@ -156,16 +163,52 @@ class _GroupFeedTabState extends State<GroupFeedTab> {
children: [
// Composer
Container(
padding: const EdgeInsets.fromLTRB(12, 10, 8, 10),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppTheme.cardSurface,
border: Border(bottom: BorderSide(color: AppTheme.navyBlue.withValues(alpha: 0.06))),
),
child: ComposerBar(
config: widget.isEncrypted
? const ComposerConfig(allowGifs: true, hintText: 'Write an encrypted post…')
: ComposerConfig.publicPost,
onSend: _onComposerSend,
child: Row(
children: [
CircleAvatar(
radius: 18,
backgroundColor: AppTheme.brightNavy.withValues(alpha: 0.1),
child: Icon(Icons.person, size: 18, color: AppTheme.brightNavy),
),
const SizedBox(width: 10),
Expanded(
child: TextField(
controller: _postCtrl,
style: TextStyle(color: SojornColors.postContent, fontSize: 14),
decoration: InputDecoration(
hintText: widget.isEncrypted ? 'Write an encrypted post…' : 'Write something…',
hintStyle: TextStyle(color: SojornColors.textDisabled),
filled: true,
fillColor: AppTheme.scaffoldBg,
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(20), borderSide: BorderSide.none),
),
textInputAction: TextInputAction.send,
onSubmitted: (_) => _createPost(),
),
),
const SizedBox(width: 8),
GestureDetector(
onTap: _createPost,
child: Container(
width: 36, height: 36,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: _posting
? AppTheme.brightNavy.withValues(alpha: 0.5)
: AppTheme.brightNavy,
),
child: _posting
? const Padding(padding: EdgeInsets.all(9), child: CircularProgressIndicator(strokeWidth: 2, color: SojornColors.basicWhite))
: const Icon(Icons.send, color: SojornColors.basicWhite, size: 16),
),
),
],
),
),
// Posts list
@ -289,12 +332,8 @@ class _PostCard extends StatelessWidget {
const SizedBox(height: 10),
ClipRRect(
borderRadius: BorderRadius.circular(10),
child: Image.network(
ApiConfig.needsProxy(imageUrl)
? ApiConfig.proxyImageUrl(imageUrl)
: imageUrl,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => const SizedBox.shrink()),
child: Image.network(imageUrl, fit: BoxFit.cover,
errorBuilder: (_, __, ___) => const SizedBox.shrink()),
),
],
// Actions
@ -344,8 +383,10 @@ class _CommentsSheet extends StatefulWidget {
}
class _CommentsSheetState extends State<_CommentsSheet> {
final _commentCtrl = TextEditingController();
List<Map<String, dynamic>> _comments = [];
bool _loading = true;
bool _sending = false;
@override
void initState() {
@ -353,6 +394,9 @@ class _CommentsSheetState extends State<_CommentsSheet> {
_loadComments();
}
@override
void dispose() { _commentCtrl.dispose(); super.dispose(); }
Future<void> _loadComments() async {
setState(() => _loading = true);
try {
@ -361,9 +405,16 @@ class _CommentsSheetState extends State<_CommentsSheet> {
if (mounted) setState(() => _loading = false);
}
Future<void> _sendComment(String text, String? _) async {
await ApiService.instance.createGroupPostComment(widget.groupId, widget.postId, body: text);
await _loadComments();
Future<void> _sendComment() async {
final text = _commentCtrl.text.trim();
if (text.isEmpty || _sending) return;
setState(() => _sending = true);
try {
await ApiService.instance.createGroupPostComment(widget.groupId, widget.postId, body: text);
_commentCtrl.clear();
await _loadComments();
} catch (_) {}
if (mounted) setState(() => _sending = false);
}
@override
@ -414,13 +465,37 @@ class _CommentsSheetState extends State<_CommentsSheet> {
),
),
const SizedBox(height: 8),
ComposerBar(
config: ComposerConfig.comment,
onSend: _sendComment,
Row(
children: [
Expanded(
child: TextField(
controller: _commentCtrl,
style: TextStyle(color: SojornColors.postContent, fontSize: 13),
decoration: InputDecoration(
hintText: 'Write a comment…',
hintStyle: TextStyle(color: SojornColors.textDisabled),
filled: true, fillColor: AppTheme.scaffoldBg,
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(20), borderSide: BorderSide.none),
),
onSubmitted: (_) => _sendComment(),
),
),
const SizedBox(width: 8),
GestureDetector(
onTap: _sendComment,
child: Container(
width: 34, height: 34,
decoration: BoxDecoration(shape: BoxShape.circle, color: AppTheme.brightNavy),
child: _sending
? const Padding(padding: EdgeInsets.all(8), child: CircularProgressIndicator(strokeWidth: 2, color: SojornColors.basicWhite))
: const Icon(Icons.send, color: SojornColors.basicWhite, size: 14),
),
),
],
),
],
),
);
}
}

View file

@ -30,7 +30,7 @@ class _GroupForumTabState extends State<GroupForumTab> {
static const _subforums = ['General', 'Events', 'Information', 'Safety', 'Recommendations', 'Marketplace'];
static const _subforumDescriptions = {
'General': 'Open public discussion',
'General': 'Open neighborhood discussion',
'Events': 'Plans, meetups, and happenings',
'Information': 'Updates, notices, and resources',
'Safety': 'Alerts and local safety conversations',
@ -50,7 +50,7 @@ class _GroupForumTabState extends State<GroupForumTab> {
if (widget.isEncrypted) {
await _loadEncryptedThreads();
} else {
// Non-encrypted public forums support sub-forums via category.
// Non-encrypted neighborhood forums support sub-forums via category.
final queryParams = <String, String>{
'limit': _activeSubforum == null ? '120' : '30',
};

View file

@ -3,7 +3,6 @@ import 'package:cryptography/cryptography.dart';
import '../../services/api_service.dart';
import '../../theme/tokens.dart';
import '../../theme/app_theme.dart';
import '../../widgets/composer/composer_bar.dart';
/// Thread detail screen with replies works for both public and encrypted groups.
/// For encrypted groups, thread detail isn't supported via the standard API yet,
@ -29,9 +28,11 @@ class GroupThreadDetailScreen extends StatefulWidget {
}
class _GroupThreadDetailScreenState extends State<GroupThreadDetailScreen> {
final _replyCtrl = TextEditingController();
Map<String, dynamic>? _thread;
List<Map<String, dynamic>> _replies = [];
bool _loading = true;
bool _sending = false;
@override
void initState() {
@ -39,6 +40,12 @@ class _GroupThreadDetailScreenState extends State<GroupThreadDetailScreen> {
_loadThread();
}
@override
void dispose() {
_replyCtrl.dispose();
super.dispose();
}
Future<void> _loadThread() async {
setState(() => _loading = true);
try {
@ -58,22 +65,20 @@ class _GroupThreadDetailScreenState extends State<GroupThreadDetailScreen> {
if (mounted) setState(() => _loading = false);
}
Future<void> _sendReply(String text, String? _) async {
await ApiService.instance.createGroupThreadReply(widget.groupId, widget.threadId, body: text);
await _loadThread();
}
int _uniqueParticipants() {
final authors = <String>{};
if (_thread != null) {
final a = _thread!['author_id']?.toString() ?? _thread!['author_handle']?.toString() ?? '';
if (a.isNotEmpty) authors.add(a);
Future<void> _sendReply() async {
final text = _replyCtrl.text.trim();
if (text.isEmpty || _sending || widget.isEncrypted) return;
setState(() => _sending = true);
try {
await ApiService.instance.createGroupThreadReply(widget.groupId, widget.threadId, body: text);
_replyCtrl.clear();
await _loadThread();
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Failed: $e')));
}
}
for (final r in _replies) {
final a = r['author_id']?.toString() ?? r['author_handle']?.toString() ?? '';
if (a.isNotEmpty) authors.add(a);
}
return authors.length;
if (mounted) setState(() => _sending = false);
}
String _timeAgo(String? dateStr) {
@ -109,67 +114,42 @@ class _GroupThreadDetailScreenState extends State<GroupThreadDetailScreen> {
child: ListView(
padding: const EdgeInsets.all(16),
children: [
// Original post (highlighted)
// Thread body
if (_thread != null) ...[
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: AppTheme.cardSurface,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: AppTheme.brightNavy.withValues(alpha: 0.25), width: 1.5),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_thread!['title'] as String? ?? '',
style: TextStyle(color: AppTheme.navyBlue, fontSize: 18, fontWeight: FontWeight.w700),
),
const SizedBox(height: 8),
Row(
children: [
Text(
_thread!['author_display_name'] as String? ??
_thread!['author_handle'] as String? ?? '',
style: TextStyle(color: AppTheme.brightNavy, fontSize: 12, fontWeight: FontWeight.w500),
),
const SizedBox(width: 8),
Text(
_timeAgo(_thread!['created_at']?.toString()),
style: TextStyle(color: SojornColors.textDisabled, fontSize: 11),
),
],
),
if ((_thread!['body'] as String? ?? '').isNotEmpty) ...[
const SizedBox(height: 12),
Text(
_thread!['body'] as String,
style: TextStyle(color: SojornColors.postContent, fontSize: 14, height: 1.5),
),
],
],
),
Text(
_thread!['title'] as String? ?? '',
style: TextStyle(color: AppTheme.navyBlue, fontSize: 18, fontWeight: FontWeight.w700),
),
const SizedBox(height: 16),
// Chain metadata
const SizedBox(height: 8),
Row(
children: [
Icon(Icons.forum_outlined, size: 14, color: AppTheme.navyBlue.withValues(alpha: 0.5)),
const SizedBox(width: 6),
Text(
'${_replies.length} ${_replies.length == 1 ? 'reply' : 'replies'}',
style: TextStyle(color: AppTheme.navyBlue, fontWeight: FontWeight.w600, fontSize: 13),
_thread!['author_display_name'] as String? ??
_thread!['author_handle'] as String? ?? '',
style: TextStyle(color: AppTheme.brightNavy, fontSize: 12, fontWeight: FontWeight.w500),
),
const SizedBox(width: 12),
Icon(Icons.people_outline, size: 14, color: AppTheme.navyBlue.withValues(alpha: 0.5)),
const SizedBox(width: 4),
const SizedBox(width: 8),
Text(
'${_uniqueParticipants()} participants',
style: TextStyle(color: SojornColors.textDisabled, fontSize: 12),
_timeAgo(_thread!['created_at']?.toString()),
style: TextStyle(color: SojornColors.textDisabled, fontSize: 11),
),
],
),
const SizedBox(height: 12),
if ((_thread!['body'] as String? ?? '').isNotEmpty) ...[
const SizedBox(height: 12),
Text(
_thread!['body'] as String,
style: TextStyle(color: SojornColors.postContent, fontSize: 14, height: 1.5),
),
],
const SizedBox(height: 16),
Divider(color: AppTheme.navyBlue.withValues(alpha: 0.08)),
const SizedBox(height: 8),
Text(
'${_replies.length} ${_replies.length == 1 ? 'Reply' : 'Replies'}',
style: TextStyle(color: AppTheme.navyBlue, fontWeight: FontWeight.w600, fontSize: 13),
),
const SizedBox(height: 8),
],
if (widget.isEncrypted && _replies.isEmpty)
Padding(
@ -181,13 +161,11 @@ class _GroupThreadDetailScreenState extends State<GroupThreadDetailScreen> {
),
),
),
// Replies with thread connector
for (int i = 0; i < _replies.length; i++)
_ReplyCard(
reply: _replies[i],
timeAgo: _timeAgo(_replies[i]['created_at']?.toString()),
showConnector: i < _replies.length - 1,
),
// Replies
..._replies.map((reply) => _ReplyCard(
reply: reply,
timeAgo: _timeAgo(reply['created_at']?.toString()),
)),
],
),
),
@ -199,9 +177,34 @@ class _GroupThreadDetailScreenState extends State<GroupThreadDetailScreen> {
color: AppTheme.cardSurface,
border: Border(top: BorderSide(color: AppTheme.navyBlue.withValues(alpha: 0.08))),
),
child: ComposerBar(
config: ComposerConfig.threadReply,
onSend: _sendReply,
child: Row(
children: [
Expanded(
child: TextField(
controller: _replyCtrl,
style: TextStyle(color: SojornColors.postContent, fontSize: 14),
decoration: InputDecoration(
hintText: 'Write a reply…',
hintStyle: TextStyle(color: SojornColors.textDisabled),
filled: true, fillColor: AppTheme.scaffoldBg,
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(20), borderSide: BorderSide.none),
),
onSubmitted: (_) => _sendReply(),
),
),
const SizedBox(width: 8),
GestureDetector(
onTap: _sendReply,
child: Container(
width: 38, height: 38,
decoration: BoxDecoration(shape: BoxShape.circle, color: AppTheme.brightNavy),
child: _sending
? const Padding(padding: EdgeInsets.all(9), child: CircularProgressIndicator(strokeWidth: 2, color: SojornColors.basicWhite))
: const Icon(Icons.send, color: SojornColors.basicWhite, size: 16),
),
),
],
),
),
],
@ -213,8 +216,7 @@ class _GroupThreadDetailScreenState extends State<GroupThreadDetailScreen> {
class _ReplyCard extends StatelessWidget {
final Map<String, dynamic> reply;
final String timeAgo;
final bool showConnector;
const _ReplyCard({required this.reply, required this.timeAgo, this.showConnector = false});
const _ReplyCard({required this.reply, required this.timeAgo});
@override
Widget build(BuildContext context) {
@ -223,71 +225,34 @@ class _ReplyCard extends StatelessWidget {
final avatarUrl = reply['author_avatar_url'] as String? ?? '';
final body = reply['body'] as String? ?? '';
return IntrinsicHeight(
child: Row(
return Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppTheme.cardSurface,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppTheme.navyBlue.withValues(alpha: 0.06)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Thread connector line
SizedBox(
width: 20,
child: Column(
children: [
Container(
width: 2, height: 8,
color: AppTheme.navyBlue.withValues(alpha: 0.12),
),
Container(
width: 8, height: 8,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: AppTheme.navyBlue.withValues(alpha: 0.15),
),
),
if (showConnector)
Expanded(
child: Container(
width: 2,
color: AppTheme.navyBlue.withValues(alpha: 0.12),
),
),
],
),
),
const SizedBox(width: 6),
// Reply content
Expanded(
child: Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppTheme.cardSurface,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppTheme.navyBlue.withValues(alpha: 0.06)),
Row(
children: [
CircleAvatar(
radius: 14,
backgroundColor: AppTheme.brightNavy.withValues(alpha: 0.1),
backgroundImage: avatarUrl.isNotEmpty ? NetworkImage(avatarUrl) : null,
child: avatarUrl.isEmpty ? Icon(Icons.person, size: 14, color: AppTheme.brightNavy) : null,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
CircleAvatar(
radius: 14,
backgroundColor: AppTheme.brightNavy.withValues(alpha: 0.1),
backgroundImage: avatarUrl.isNotEmpty ? NetworkImage(avatarUrl) : null,
child: avatarUrl.isEmpty ? Icon(Icons.person, size: 14, color: AppTheme.brightNavy) : null,
),
const SizedBox(width: 8),
Text(displayName.isNotEmpty ? displayName : handle,
style: TextStyle(color: AppTheme.navyBlue, fontWeight: FontWeight.w600, fontSize: 12)),
const SizedBox(width: 6),
Text(timeAgo, style: TextStyle(color: SojornColors.textDisabled, fontSize: 10)),
],
),
const SizedBox(height: 8),
Text(body, style: TextStyle(color: SojornColors.postContent, fontSize: 13, height: 1.4)),
],
),
),
const SizedBox(width: 8),
Text(displayName.isNotEmpty ? displayName : handle,
style: TextStyle(color: AppTheme.navyBlue, fontWeight: FontWeight.w600, fontSize: 12)),
const SizedBox(width: 6),
Text(timeAgo, style: TextStyle(color: SojornColors.textDisabled, fontSize: 10)),
],
),
const SizedBox(height: 8),
Text(body, style: TextStyle(color: SojornColors.postContent, fontSize: 13, height: 1.4)),
],
),
);

View file

@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cryptography/cryptography.dart';
import '../../models/cluster.dart';
import '../../providers/api_provider.dart';
import '../../services/api_service.dart';
import '../../services/auth_service.dart';
import '../../services/capsule_security_service.dart';
@ -59,71 +58,11 @@ class _PrivateCapsuleScreenState extends ConsumerState<PrivateCapsuleScreen>
await CapsuleSecurityService.cacheCapsuleKey(widget.capsule.id, key);
}
if (mounted) setState(() { _capsuleKey = key; _isUnlocking = false; });
// Silent self-healing: rotate keys if the server flagged it
if (key != null) _checkAndRotateKeysIfNeeded();
} catch (e) {
if (mounted) setState(() { _unlockError = 'Failed to unlock capsule'; _isUnlocking = false; });
}
}
/// Silently check if key rotation is needed and perform it automatically.
Future<void> _checkAndRotateKeysIfNeeded() async {
try {
final api = ref.read(apiServiceProvider);
final status = await api.callGoApi('/groups/${widget.capsule.id}/key-status', method: 'GET');
final rotationNeeded = status['key_rotation_needed'] as bool? ?? false;
if (!rotationNeeded || !mounted) return;
// Perform rotation silently user sees nothing
await _performKeyRotation(api, silent: true);
} catch (_) {
// Non-fatal: rotation will be retried on next open
}
}
/// Full key rotation: fetch member public keys, generate new AES key,
/// encrypt for each member, push to server.
Future<void> _performKeyRotation(ApiService api, {bool silent = false}) async {
// Fetch member public keys
final keysData = await api.callGoApi(
'/groups/${widget.capsule.id}/members/public-keys',
method: 'GET',
);
final memberKeys = (keysData['keys'] as List?)?.cast<Map<String, dynamic>>() ?? [];
if (memberKeys.isEmpty) return;
final pubKeys = memberKeys.map((m) => m['public_key'] as String).toList();
final userIds = memberKeys.map((m) => m['user_id'] as String).toList();
final result = await CapsuleSecurityService.rotateKeys(
memberPublicKeysB64: pubKeys,
memberUserIds: userIds,
);
// Determine next key version
final status = await api.callGoApi('/groups/${widget.capsule.id}/key-status', method: 'GET');
final currentVersion = status['key_version'] as int? ?? 1;
final nextVersion = currentVersion + 1;
final payload = result.memberKeys.entries.map((e) => {
'user_id': e.key,
'encrypted_key': e.value,
'key_version': nextVersion,
}).toList();
await api.callGoApi('/groups/${widget.capsule.id}/keys', method: 'POST', body: {'keys': payload});
// Update local cache with new key
await CapsuleSecurityService.cacheCapsuleKey(widget.capsule.id, result.newCapsuleKey);
if (mounted) setState(() => _capsuleKey = result.newCapsuleKey);
if (!silent && mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Keys rotated successfully')),
);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
@ -291,11 +230,7 @@ class _PrivateCapsuleScreenState extends ConsumerState<PrivateCapsuleScreen>
context: context,
backgroundColor: AppTheme.cardSurface,
isScrollControlled: true,
builder: (ctx) => _CapsuleAdminPanel(
capsule: widget.capsule,
capsuleKey: _capsuleKey,
onRotateKeys: () => _performKeyRotation(ref.read(apiServiceProvider)),
),
builder: (ctx) => _CapsuleAdminPanel(capsule: widget.capsule),
);
}
}
@ -1074,141 +1009,9 @@ class _NewVaultNoteSheetState extends State<_NewVaultNoteSheet> {
}
// Admin Panel
class _CapsuleAdminPanel extends ConsumerStatefulWidget {
class _CapsuleAdminPanel extends StatelessWidget {
final Cluster capsule;
final SecretKey? capsuleKey;
final Future<void> Function() onRotateKeys;
const _CapsuleAdminPanel({
required this.capsule,
required this.capsuleKey,
required this.onRotateKeys,
});
@override
ConsumerState<_CapsuleAdminPanel> createState() => _CapsuleAdminPanelState();
}
class _CapsuleAdminPanelState extends ConsumerState<_CapsuleAdminPanel> {
bool _busy = false;
Future<void> _rotateKeys() async {
setState(() => _busy = true);
try {
await widget.onRotateKeys();
if (mounted) Navigator.pop(context);
} catch (e) {
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Rotation failed: $e')));
} finally {
if (mounted) setState(() => _busy = false);
}
}
Future<void> _inviteMember() async {
final handle = await showDialog<String>(
context: context,
builder: (ctx) => _TextInputDialog(
title: 'Invite Member',
label: 'Username or @handle',
action: 'Invite',
),
);
if (handle == null || handle.isEmpty) return;
setState(() => _busy = true);
try {
final api = ref.read(apiServiceProvider);
// Look up user by handle
final userData = await api.callGoApi(
'/users/by-handle/${handle.replaceFirst('@', '')}',
method: 'GET',
);
final userId = userData['id'] as String?;
final recipientPubKey = userData['public_key'] as String?;
if (userId == null) throw 'User not found';
if (recipientPubKey == null || recipientPubKey.isEmpty) throw 'User has no public key registered';
if (widget.capsuleKey == null) throw 'Capsule not unlocked';
// Encrypt the current group key for the new member
final encryptedKey = await CapsuleSecurityService.encryptCapsuleKeyForUser(
capsuleKey: widget.capsuleKey!,
recipientPublicKeyB64: recipientPubKey,
);
await api.callGoApi('/groups/${widget.capsule.id}/invite-member', method: 'POST', body: {
'user_id': userId,
'encrypted_key': encryptedKey,
});
if (mounted) {
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('${handle.replaceFirst('@', '')} invited')),
);
}
} catch (e) {
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Invite failed: $e')));
} finally {
if (mounted) setState(() => _busy = false);
}
}
Future<void> _removeMember() async {
final api = ref.read(apiServiceProvider);
// Load member list
final data = await api.callGoApi('/groups/${widget.capsule.id}/members', method: 'GET');
final members = (data['members'] as List?)?.cast<Map<String, dynamic>>() ?? [];
if (!mounted) return;
final selected = await showDialog<Map<String, dynamic>>(
context: context,
builder: (ctx) => _MemberPickerDialog(members: members),
);
if (selected == null || !mounted) return;
final confirm = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Remove Member'),
content: Text('Remove ${selected['username']}? This will trigger key rotation.'),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('Cancel')),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
style: TextButton.styleFrom(foregroundColor: SojornColors.destructive),
child: const Text('Remove'),
),
],
),
);
if (confirm != true) return;
setState(() => _busy = true);
try {
await api.callGoApi(
'/groups/${widget.capsule.id}/members/${selected['user_id']}',
method: 'DELETE',
);
// Rotate keys after removal server already flagged it; do it now
await widget.onRotateKeys();
if (mounted) Navigator.pop(context);
} catch (e) {
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Remove failed: $e')));
} finally {
if (mounted) setState(() => _busy = false);
}
}
Future<void> _openSettings() async {
Navigator.pop(context);
await showDialog<void>(
context: context,
builder: (ctx) => _CapsuleSettingsDialog(capsule: widget.capsule),
);
}
const _CapsuleAdminPanel({required this.capsule});
@override
Widget build(BuildContext context) {
@ -1218,6 +1021,7 @@ class _CapsuleAdminPanelState extends ConsumerState<_CapsuleAdminPanel> {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Handle
Center(
child: Container(
width: 40, height: 4,
@ -1228,179 +1032,49 @@ class _CapsuleAdminPanelState extends ConsumerState<_CapsuleAdminPanel> {
),
),
const SizedBox(height: 16),
Text('Capsule Admin',
style: TextStyle(color: AppTheme.navyBlue, fontSize: 18, fontWeight: FontWeight.w700)),
Text(
'Capsule Admin',
style: TextStyle(color: AppTheme.navyBlue, fontSize: 18, fontWeight: FontWeight.w700),
),
const SizedBox(height: 20),
if (_busy)
const Center(child: Padding(
padding: EdgeInsets.symmetric(vertical: 24),
child: CircularProgressIndicator(),
))
else ...[
_AdminAction(
icon: Icons.vpn_key,
label: 'Rotate Encryption Keys',
subtitle: 'Generate new keys and re-encrypt for all members',
color: const Color(0xFFFFA726),
onTap: _rotateKeys,
),
const SizedBox(height: 8),
_AdminAction(
icon: Icons.person_add,
label: 'Invite Member',
subtitle: 'Encrypt the capsule key for a new member',
color: const Color(0xFF4CAF50),
onTap: _inviteMember,
),
const SizedBox(height: 8),
_AdminAction(
icon: Icons.person_remove,
label: 'Remove Member',
subtitle: 'Revoke access and rotate keys automatically',
color: SojornColors.destructive,
onTap: _removeMember,
),
const SizedBox(height: 8),
_AdminAction(
icon: Icons.settings,
label: 'Capsule Settings',
subtitle: 'Toggle chat, forum, and vault features',
color: SojornColors.basicBrightNavy,
onTap: _openSettings,
),
],
_AdminAction(
icon: Icons.vpn_key,
label: 'Rotate Encryption Keys',
subtitle: 'Generate new keys and re-encrypt for all members',
color: const Color(0xFFFFA726),
onTap: () { Navigator.pop(context); /* TODO: key rotation flow */ },
),
const SizedBox(height: 8),
_AdminAction(
icon: Icons.person_add,
label: 'Invite Member',
subtitle: 'Encrypt the capsule key for a new member',
color: const Color(0xFF4CAF50),
onTap: () { Navigator.pop(context); /* TODO: invite flow */ },
),
const SizedBox(height: 8),
_AdminAction(
icon: Icons.person_remove,
label: 'Remove Member',
subtitle: 'Revoke access (triggers automatic key rotation)',
color: SojornColors.destructive,
onTap: () { Navigator.pop(context); /* TODO: remove + rotate */ },
),
const SizedBox(height: 8),
_AdminAction(
icon: Icons.settings,
label: 'Capsule Settings',
subtitle: 'Toggle chat, forum, and vault features',
color: SojornColors.basicBrightNavy,
onTap: () { Navigator.pop(context); /* TODO: settings */ },
),
],
),
);
}
}
// Helper dialogs
class _TextInputDialog extends StatefulWidget {
final String title;
final String label;
final String action;
const _TextInputDialog({required this.title, required this.label, required this.action});
@override
State<_TextInputDialog> createState() => _TextInputDialogState();
}
class _TextInputDialogState extends State<_TextInputDialog> {
final _ctrl = TextEditingController();
@override
void dispose() { _ctrl.dispose(); super.dispose(); }
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text(widget.title),
content: TextField(
controller: _ctrl,
decoration: InputDecoration(labelText: widget.label),
autofocus: true,
onSubmitted: (v) => Navigator.pop(context, v.trim()),
),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')),
TextButton(
onPressed: () => Navigator.pop(context, _ctrl.text.trim()),
child: Text(widget.action),
),
],
);
}
}
class _MemberPickerDialog extends StatelessWidget {
final List<Map<String, dynamic>> members;
const _MemberPickerDialog({required this.members});
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Select Member to Remove'),
content: SizedBox(
width: double.maxFinite,
child: ListView.builder(
shrinkWrap: true,
itemCount: members.length,
itemBuilder: (ctx, i) {
final m = members[i];
return ListTile(
title: Text(m['username'] as String? ?? m['user_id'] as String? ?? ''),
subtitle: Text(m['role'] as String? ?? ''),
onTap: () => Navigator.pop(context, m),
);
},
),
),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')),
],
);
}
}
class _CapsuleSettingsDialog extends ConsumerStatefulWidget {
final Cluster capsule;
const _CapsuleSettingsDialog({required this.capsule});
@override
ConsumerState<_CapsuleSettingsDialog> createState() => _CapsuleSettingsDialogState();
}
class _CapsuleSettingsDialogState extends ConsumerState<_CapsuleSettingsDialog> {
bool _chat = true;
bool _forum = true;
bool _vault = true;
bool _saving = false;
@override
void initState() {
super.initState();
_chat = widget.capsule.settings.chat;
_forum = widget.capsule.settings.forum;
_vault = widget.capsule.settings.files;
}
Future<void> _save() async {
setState(() => _saving = true);
try {
final api = ref.read(apiServiceProvider);
await api.callGoApi('/groups/${widget.capsule.id}/settings', method: 'PATCH', body: {
'chat_enabled': _chat,
'forum_enabled': _forum,
'vault_enabled': _vault,
});
if (mounted) Navigator.pop(context);
} catch (e) {
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Save failed: $e')));
} finally {
if (mounted) setState(() => _saving = false);
}
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Capsule Settings'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
SwitchListTile(title: const Text('Chat'), value: _chat, onChanged: (v) => setState(() => _chat = v)),
SwitchListTile(title: const Text('Forum'), value: _forum, onChanged: (v) => setState(() => _forum = v)),
SwitchListTile(title: const Text('Vault'), value: _vault, onChanged: (v) => setState(() => _vault = v)),
],
),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')),
ElevatedButton(
onPressed: _saving ? null : _save,
child: _saving ? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)) : const Text('Save'),
),
],
);
}
}
class _AdminAction extends StatelessWidget {
final IconData icon;
final String label;

View file

@ -31,11 +31,14 @@ class _PublicClusterScreenState extends ConsumerState<PublicClusterScreen> {
Future<void> _loadPosts() async {
setState(() => _isLoading = true);
try {
// TODO: Call group-specific feed endpoint when wired
final api = ref.read(apiServiceProvider);
final raw = await api.callGoApi('/groups/${widget.cluster.id}/feed', method: 'GET');
final items = (raw['posts'] as List?)?.cast<Map<String, dynamic>>() ?? [];
final posts = items.map((j) => Post.fromJson(j)).toList();
if (mounted) setState(() { _posts = posts; _isLoading = false; });
final beacons = await api.fetchNearbyBeacons(
lat: widget.cluster.lat ?? 0,
long: widget.cluster.lng ?? 0,
radius: widget.cluster.radiusMeters,
);
if (mounted) setState(() { _posts = beacons; _isLoading = false; });
} catch (_) {
if (mounted) setState(() => _isLoading = false);
}

View file

@ -16,9 +16,7 @@ import '../../services/api_service.dart';
import '../../services/image_upload_service.dart';
import '../../theme/app_theme.dart';
import '../../theme/tokens.dart';
import '../../config/api_config.dart';
import '../../widgets/composer/composer_toolbar.dart';
import '../../widgets/gif/gif_picker.dart';
import '../../services/content_filter.dart';
import '../../widgets/sojorn_snackbar.dart';
import 'image_editor_screen.dart';
@ -63,7 +61,6 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
Uint8List? _selectedImageBytes;
String? _selectedImageName;
ImageFilter? _selectedFilter;
String? _selectedGifUrl;
final ImagePicker _imagePicker = ImagePicker();
static const double _editorFontSize = 18;
List<String> _tagSuggestions = [];
@ -278,19 +275,6 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
_selectedImageBytes = null;
_selectedImageName = null;
_selectedFilter = null;
_selectedGifUrl = null;
});
}
void _openGifPicker() {
showGifPicker(context, onSelected: (url) {
setState(() {
_selectedGifUrl = url;
_selectedImageFile = null;
_selectedImageBytes = null;
_selectedImageName = null;
_selectedFilter = null;
});
});
}
@ -417,7 +401,7 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
});
try {
String? imageUrl = _selectedGifUrl;
String? imageUrl;
if (_selectedImageFile != null || _selectedImageBytes != null) {
setState(() {
@ -666,9 +650,6 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
!isKeyboardOpen
? _buildImagePreview()
: null,
gifPreviewWidget: _selectedGifUrl != null && !isKeyboardOpen
? _buildGifPreview()
: null,
linkPreviewWidget: !_isTyping && _linkPreview != null && !isKeyboardOpen
? _buildComposeLinkPreview()
: null,
@ -687,7 +668,6 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
bottom: MediaQuery.of(context).viewInsets.bottom),
child: ComposeBottomBar(
onAddMedia: _pickMedia,
onAddGif: _openGifPicker,
onToggleBold: _toggleBold,
onToggleItalic: _toggleItalic,
onToggleChain: _toggleChain,
@ -754,44 +734,6 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
);
}
Widget _buildGifPreview() {
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: AppTheme.spacingLg, vertical: AppTheme.spacingSm),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Stack(
children: [
Image.network(
ApiConfig.needsProxy(_selectedGifUrl!)
? ApiConfig.proxyImageUrl(_selectedGifUrl!)
: _selectedGifUrl!,
height: 150,
width: double.infinity,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => const SizedBox.shrink(),
),
Positioned(
top: 8, right: 8,
child: Material(
color: SojornColors.overlayDark,
shape: const CircleBorder(),
child: InkWell(
onTap: _removeImage,
customBorder: const CircleBorder(),
child: const Padding(
padding: EdgeInsets.all(6),
child: Icon(Icons.close, color: SojornColors.basicWhite, size: 18),
),
),
),
),
],
),
),
);
}
Widget _buildComposeLinkPreview() {
final preview = _linkPreview!;
final domain = preview['domain'] as String? ?? '';
@ -953,7 +895,6 @@ class ComposeBody extends StatelessWidget {
final bool isBold;
final bool isItalic;
final Widget? imageWidget;
final Widget? gifPreviewWidget;
final Widget? linkPreviewWidget;
final List<String> suggestions;
final ValueChanged<String> onSelectSuggestion;
@ -967,7 +908,6 @@ class ComposeBody extends StatelessWidget {
required this.suggestions,
required this.onSelectSuggestion,
this.imageWidget,
this.gifPreviewWidget,
this.linkPreviewWidget,
});
@ -1042,7 +982,6 @@ class ComposeBody extends StatelessWidget {
),
if (linkPreviewWidget != null) linkPreviewWidget!,
if (imageWidget != null) imageWidget!,
if (gifPreviewWidget != null) gifPreviewWidget!,
],
);
}
@ -1051,7 +990,6 @@ class ComposeBody extends StatelessWidget {
/// Bottom bar pinned above the keyboard with formatting + counter
class ComposeBottomBar extends StatelessWidget {
final VoidCallback onAddMedia;
final VoidCallback? onAddGif;
final VoidCallback onToggleBold;
final VoidCallback onToggleItalic;
final VoidCallback onToggleChain;
@ -1071,7 +1009,6 @@ class ComposeBottomBar extends StatelessWidget {
const ComposeBottomBar({
super.key,
required this.onAddMedia,
this.onAddGif,
required this.onToggleBold,
required this.onToggleItalic,
required this.onToggleChain,
@ -1108,7 +1045,6 @@ class ComposeBottomBar extends StatelessWidget {
top: false,
child: ComposerToolbar(
onAddMedia: onAddMedia,
onAddGif: onAddGif,
onToggleBold: onToggleBold,
onToggleItalic: onToggleItalic,
onToggleChain: onToggleChain,

View file

@ -3,11 +3,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../providers/api_provider.dart';
import '../../providers/feed_refresh_provider.dart';
import '../../models/post.dart';
import '../../models/feed_filter.dart';
import '../../theme/app_theme.dart';
import '../../widgets/sojorn_post_card.dart';
import '../../widgets/app_scaffold.dart';
import '../../widgets/feed_filter_button.dart';
import '../compose/compose_screen.dart';
import '../post/post_detail_screen.dart';
import '../../widgets/first_use_hint.dart';
@ -25,7 +23,6 @@ class _FeedPersonalScreenState extends ConsumerState<FeedPersonalScreen> {
bool _isLoading = false;
bool _hasMore = true;
String? _error;
FeedFilter _currentFilter = FeedFilter.all;
@override
void initState() {
@ -55,7 +52,6 @@ class _FeedPersonalScreenState extends ConsumerState<FeedPersonalScreen> {
final posts = await apiService.getPersonalFeed(
limit: 50,
offset: refresh ? 0 : _posts.length,
filterType: _currentFilter.typeValue,
);
_setStateIfMounted(() {
@ -95,11 +91,6 @@ class _FeedPersonalScreenState extends ConsumerState<FeedPersonalScreen> {
FocusManager.instance.primaryFocus?.unfocus();
}
void _onFilterChanged(FeedFilter filter) {
setState(() => _currentFilter = filter);
_loadPosts(refresh: true);
}
@override
Widget build(BuildContext context) {
ref.listen<int>(feedRefreshProvider, (_, __) {
@ -109,12 +100,6 @@ class _FeedPersonalScreenState extends ConsumerState<FeedPersonalScreen> {
return AppScaffold(
title: '',
showAppBar: false,
actions: [
FeedFilterButton(
currentFilter: _currentFilter,
onFilterChanged: _onFilterChanged,
),
],
body: _error != null
? _ErrorState(
message: _error!,

View file

@ -2,7 +2,6 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:share_plus/share_plus.dart';
import '../../providers/api_provider.dart';
import '../../providers/feed_refresh_provider.dart';
import '../../models/post.dart';
@ -166,8 +165,7 @@ class _FeedsojornScreenState extends ConsumerState<FeedsojornScreen> {
}
void _sharePost(Post post) {
final text = post.body.isNotEmpty ? post.body : 'Check this out on Sojorn';
Share.share(text, subject: 'Shared from Sojorn');
// TODO: Implement share functionality
}
@override
@ -311,7 +309,7 @@ class _FeedsojornScreenState extends ConsumerState<FeedsojornScreen> {
const SizedBox(height: 4),
Text(
'items: ${_feedItems.length} | ads: ${adIndices.length}',
style: TextStyle(
style: const TextStyle(
color: SojornColors.basicWhite.withValues(alpha: 0.7),
fontSize: 11,
),
@ -321,7 +319,7 @@ class _FeedsojornScreenState extends ConsumerState<FeedsojornScreen> {
adIndices.isEmpty
? 'ad positions: none'
: 'ad positions: ${adIndices.join(', ')}',
style: TextStyle(
style: const TextStyle(
color: SojornColors.basicWhite.withValues(alpha: 0.7),
fontSize: 11,
),

View file

@ -13,12 +13,7 @@ import '../discover/discover_screen.dart';
import '../beacon/beacon_screen.dart';
import '../quips/create/quip_creation_flow.dart';
import '../secure_chat/secure_chat_full_screen.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../widgets/radial_menu_overlay.dart';
import '../../widgets/onboarding_modal.dart';
import '../../widgets/offline_indicator.dart';
import '../../widgets/neighborhood/neighborhood_picker_sheet.dart';
import '../../services/api_service.dart';
import '../../providers/quip_upload_provider.dart';
import '../../providers/notification_provider.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@ -39,74 +34,12 @@ class _HomeShellState extends ConsumerState<HomeShell> with WidgetsBindingObserv
final SecureChatService _chatService = SecureChatService();
StreamSubscription<RemoteMessage>? _notifSub;
// Nav helper badges show descriptive subtitle for first N taps
static const _maxHelperShows = 3;
Map<int, int> _navTapCounts = {};
static const _helperBadges = {
1: 'Videos', // Quips tab
2: 'Alerts', // Beacons tab
};
static const _longPressTooltips = {
0: 'Your main feed with posts from people you follow',
1: 'Quips are short-form videos — your stories',
2: 'Beacons are local alerts and real-time updates',
3: 'Your profile, settings, and saved posts',
};
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_chatService.startBackgroundSync();
_initNotificationListener();
_loadNavTapCounts();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
OnboardingModal.showIfNeeded(context);
_checkNeighborhoodOnboarding();
}
});
}
Future<void> _loadNavTapCounts() async {
final prefs = await SharedPreferences.getInstance();
if (mounted) {
setState(() {
_navTapCounts = {
for (final i in [1, 2])
i: prefs.getInt('nav_tap_$i') ?? 0,
};
});
}
}
Future<void> _incrementNavTap(int index) async {
if (!_helperBadges.containsKey(index)) return;
final prefs = await SharedPreferences.getInstance();
final current = prefs.getInt('nav_tap_$index') ?? 0;
await prefs.setInt('nav_tap_$index', current + 1);
if (mounted) {
setState(() => _navTapCounts[index] = current + 1);
}
}
Future<void> _checkNeighborhoodOnboarding() async {
try {
final data = await ApiService.instance.getMyNeighborhood();
if (data == null) return;
final onboarded = data['onboarded'] as bool? ?? false;
if (!onboarded && mounted) {
// Small delay so the onboarding modal (if shown) has time to appear first
await Future.delayed(const Duration(milliseconds: 800));
if (mounted) {
await NeighborhoodPickerSheet.show(context);
}
}
} catch (_) {
// Non-critical silently ignore if network unavailable
}
}
void _initNotificationListener() {
@ -139,19 +72,14 @@ class _HomeShellState extends ConsumerState<HomeShell> with WidgetsBindingObserv
final currentIndex = widget.navigationShell.currentIndex;
return Scaffold(
backgroundColor: AppTheme.scaffoldBg,
appBar: _buildAppBar(),
body: Column(
body: Stack(
children: [
const OfflineIndicator(),
Expanded(
child: Stack(
children: [
NavigationShellScope(
currentIndex: currentIndex,
child: widget.navigationShell,
),
RadialMenuOverlay(
NavigationShellScope(
currentIndex: currentIndex,
child: widget.navigationShell,
),
RadialMenuOverlay(
isVisible: _isRadialMenuVisible,
onDismiss: () => setState(() => _isRadialMenuVisible = false),
onPostTap: () {
@ -169,12 +97,12 @@ class _HomeShellState extends ConsumerState<HomeShell> with WidgetsBindingObserv
);
},
onBeaconTap: () {
setState(() => _isRadialMenuVisible = false);
widget.navigationShell.goBranch(2); // Navigate to beacon tab (index 2)
},
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => BeaconScreen(),
),
],
),
);
},
),
],
),
@ -437,126 +365,45 @@ class _HomeShellState extends ConsumerState<HomeShell> with WidgetsBindingObserv
String? assetPath,
}) {
final isActive = widget.navigationShell.currentIndex == index;
final helperBadge = _helperBadges[index];
final tapCount = _navTapCounts[index] ?? 0;
final showHelper = helperBadge != null && tapCount < _maxHelperShows;
final tooltip = _longPressTooltips[index];
return Expanded(
child: GestureDetector(
onLongPress: tooltip != null ? () {
final overlay = Overlay.of(context);
late OverlayEntry entry;
entry = OverlayEntry(
builder: (ctx) => _NavTooltipOverlay(
message: tooltip,
onDismiss: () => entry.remove(),
),
child: InkWell(
onTap: () {
widget.navigationShell.goBranch(
index,
initialLocation: index == widget.navigationShell.currentIndex,
);
overlay.insert(entry);
Future.delayed(const Duration(seconds: 2), () {
if (entry.mounted) entry.remove();
});
} : null,
child: InkWell(
onTap: () {
_incrementNavTap(index);
widget.navigationShell.goBranch(
index,
initialLocation: index == widget.navigationShell.currentIndex,
);
},
child: Container(
height: double.infinity,
alignment: Alignment.center,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Stack(
clipBehavior: Clip.none,
children: [
assetPath != null
? Image.asset(
assetPath,
width: SojornNav.bottomBarIconSize,
height: SojornNav.bottomBarIconSize,
color: isActive ? AppTheme.navyBlue : SojornColors.bottomNavUnselected,
)
: Icon(
isActive ? activeIcon : icon,
color: isActive ? AppTheme.navyBlue : SojornColors.bottomNavUnselected,
size: SojornNav.bottomBarIconSize,
),
if (showHelper)
Positioned(
right: -18,
top: -4,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
decoration: BoxDecoration(
color: AppTheme.brightNavy,
borderRadius: BorderRadius.circular(6),
),
child: Text(helperBadge, style: const TextStyle(
fontSize: 8, fontWeight: FontWeight.w700, color: Colors.white,
)),
),
),
],
},
child: Container(
height: double.infinity,
alignment: Alignment.center,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
assetPath != null
? Image.asset(
assetPath,
width: SojornNav.bottomBarIconSize,
height: SojornNav.bottomBarIconSize,
color: isActive ? AppTheme.navyBlue : SojornColors.bottomNavUnselected,
)
: Icon(
isActive ? activeIcon : icon,
color: isActive ? AppTheme.navyBlue : SojornColors.bottomNavUnselected,
size: SojornNav.bottomBarIconSize,
),
SizedBox(height: SojornNav.bottomBarLabelTopGap),
Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: SojornNav.bottomBarLabelSize,
fontWeight: isActive ? FontWeight.w700 : FontWeight.w600,
color: isActive ? AppTheme.navyBlue : SojornColors.bottomNavUnselected,
),
SizedBox(height: SojornNav.bottomBarLabelTopGap),
Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: SojornNav.bottomBarLabelSize,
fontWeight: isActive ? FontWeight.w700 : FontWeight.w600,
color: isActive ? AppTheme.navyBlue : SojornColors.bottomNavUnselected,
),
),
],
),
),
),
),
);
}
}
// Nav Tooltip Overlay (long-press on nav items)
class _NavTooltipOverlay extends StatelessWidget {
final String message;
final VoidCallback onDismiss;
const _NavTooltipOverlay({required this.message, required this.onDismiss});
@override
Widget build(BuildContext context) {
return Positioned.fill(
child: GestureDetector(
onTap: onDismiss,
behavior: HitTestBehavior.translucent,
child: Align(
alignment: const Alignment(0, 0.85),
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 32),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
decoration: BoxDecoration(
color: AppTheme.navyBlue,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.2),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
child: Text(message, style: const TextStyle(
color: Colors.white, fontSize: 13, fontWeight: FontWeight.w500,
), textAlign: TextAlign.center),
),
],
),
),
),

View file

@ -1,429 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../models/profile_privacy_settings.dart';
import '../../providers/api_provider.dart';
import '../../theme/app_theme.dart';
import '../../theme/tokens.dart';
/// Privacy Dashboard a single-screen overview of all privacy settings
/// with inline toggles and visual status indicators.
class PrivacyDashboardScreen extends ConsumerStatefulWidget {
const PrivacyDashboardScreen({super.key});
@override
ConsumerState<PrivacyDashboardScreen> createState() => _PrivacyDashboardScreenState();
}
class _PrivacyDashboardScreenState extends ConsumerState<PrivacyDashboardScreen> {
ProfilePrivacySettings? _settings;
bool _isLoading = true;
bool _isSaving = false;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
setState(() => _isLoading = true);
try {
final api = ref.read(apiServiceProvider);
final settings = await api.getPrivacySettings();
if (mounted) setState(() => _settings = settings);
} catch (_) {}
if (mounted) setState(() => _isLoading = false);
}
Future<void> _save(ProfilePrivacySettings updated) async {
setState(() {
_settings = updated;
_isSaving = true;
});
try {
final api = ref.read(apiServiceProvider);
await api.updatePrivacySettings(updated);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to save: $e'), backgroundColor: Colors.red),
);
}
}
if (mounted) setState(() => _isSaving = false);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppTheme.scaffoldBg,
appBar: AppBar(
backgroundColor: AppTheme.scaffoldBg,
surfaceTintColor: SojornColors.transparent,
title: const Text('Privacy Dashboard', style: TextStyle(fontWeight: FontWeight.w800)),
actions: [
if (_isSaving)
const Padding(
padding: EdgeInsets.all(16),
child: SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2)),
),
],
),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: _settings == null
? Center(child: Text('Could not load settings', style: TextStyle(color: SojornColors.textDisabled)))
: ListView(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
children: [
// Privacy score summary
_PrivacyScoreCard(settings: _settings!),
const SizedBox(height: 20),
// Account Visibility
_SectionTitle(title: 'Account Visibility'),
const SizedBox(height: 8),
_ToggleTile(
icon: Icons.lock_outline,
title: 'Private Profile',
subtitle: 'Only followers can see your posts',
value: _settings!.isPrivate,
onChanged: (v) => _save(_settings!.copyWith(isPrivate: v)),
),
_ToggleTile(
icon: Icons.search,
title: 'Appear in Search',
subtitle: 'Let others find you by name or handle',
value: _settings!.showInSearch,
onChanged: (v) => _save(_settings!.copyWith(showInSearch: v)),
),
_ToggleTile(
icon: Icons.recommend,
title: 'Appear in Suggestions',
subtitle: 'Show in "People you may know"',
value: _settings!.showInSuggestions,
onChanged: (v) => _save(_settings!.copyWith(showInSuggestions: v)),
),
_ToggleTile(
icon: Icons.circle,
title: 'Activity Status',
subtitle: 'Show when you\'re online',
value: _settings!.showActivityStatus,
onChanged: (v) => _save(_settings!.copyWith(showActivityStatus: v)),
),
const SizedBox(height: 20),
// Content Controls
_SectionTitle(title: 'Content Controls'),
const SizedBox(height: 8),
_ChoiceTile(
icon: Icons.article_outlined,
title: 'Default Post Visibility',
value: _settings!.defaultVisibility,
options: const {'public': 'Public', 'followers': 'Followers', 'private': 'Only Me'},
onChanged: (v) => _save(_settings!.copyWith(defaultVisibility: v)),
),
_ToggleTile(
icon: Icons.link,
title: 'Allow Chains',
subtitle: 'Let others reply-chain to your posts',
value: _settings!.allowChains,
onChanged: (v) => _save(_settings!.copyWith(allowChains: v)),
),
const SizedBox(height: 20),
// Interaction Controls
_SectionTitle(title: 'Interaction Controls'),
const SizedBox(height: 8),
_ChoiceTile(
icon: Icons.chat_bubble_outline,
title: 'Who Can Message',
value: _settings!.whoCanMessage,
options: const {'everyone': 'Everyone', 'followers': 'Followers', 'nobody': 'Nobody'},
onChanged: (v) => _save(_settings!.copyWith(whoCanMessage: v)),
),
_ChoiceTile(
icon: Icons.comment_outlined,
title: 'Who Can Comment',
value: _settings!.whoCanComment,
options: const {'everyone': 'Everyone', 'followers': 'Followers', 'nobody': 'Nobody'},
onChanged: (v) => _save(_settings!.copyWith(whoCanComment: v)),
),
_ChoiceTile(
icon: Icons.person_add_outlined,
title: 'Follow Requests',
value: _settings!.followRequestPolicy,
options: const {'everyone': 'Auto-accept', 'manual': 'Manual Approval'},
onChanged: (v) => _save(_settings!.copyWith(followRequestPolicy: v)),
),
const SizedBox(height: 20),
// Data & Encryption
_SectionTitle(title: 'Data & Encryption'),
const SizedBox(height: 8),
_InfoTile(
icon: Icons.shield_outlined,
title: 'End-to-End Encryption',
subtitle: 'Capsule messages are always E2EE',
badge: 'Active',
badgeColor: const Color(0xFF4CAF50),
),
_InfoTile(
icon: Icons.vpn_key_outlined,
title: 'ALTCHA Verification',
subtitle: 'Proof-of-work protects your account',
badge: 'Active',
badgeColor: const Color(0xFF4CAF50),
),
const SizedBox(height: 32),
],
),
);
}
}
// Privacy Score Card
class _PrivacyScoreCard extends StatelessWidget {
final ProfilePrivacySettings settings;
const _PrivacyScoreCard({required this.settings});
int _calculateScore() {
int score = 50; // base
if (settings.isPrivate) score += 15;
if (!settings.showActivityStatus) score += 5;
if (!settings.showInSuggestions) score += 5;
if (settings.whoCanMessage == 'followers') score += 5;
if (settings.whoCanMessage == 'nobody') score += 10;
if (settings.whoCanComment == 'followers') score += 5;
if (settings.whoCanComment == 'nobody') score += 10;
if (settings.defaultVisibility == 'followers') score += 5;
if (settings.defaultVisibility == 'private') score += 10;
if (settings.followRequestPolicy == 'manual') score += 5;
return score.clamp(0, 100);
}
@override
Widget build(BuildContext context) {
final score = _calculateScore();
final label = score >= 80 ? 'Fort Knox' : score >= 60 ? 'Well Protected' : score >= 40 ? 'Balanced' : 'Open';
final color = score >= 80 ? const Color(0xFF4CAF50) : score >= 60 ? const Color(0xFF2196F3) : score >= 40 ? const Color(0xFFFFC107) : const Color(0xFFFF9800);
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [color.withValues(alpha: 0.08), color.withValues(alpha: 0.03)],
begin: Alignment.topLeft, end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(18),
border: Border.all(color: color.withValues(alpha: 0.2)),
),
child: Row(
children: [
SizedBox(
width: 60, height: 60,
child: Stack(
alignment: Alignment.center,
children: [
CircularProgressIndicator(
value: score / 100,
strokeWidth: 5,
backgroundColor: color.withValues(alpha: 0.15),
valueColor: AlwaysStoppedAnimation(color),
),
Text('$score', style: TextStyle(
fontSize: 18, fontWeight: FontWeight.w800, color: color,
)),
],
),
),
const SizedBox(width: 18),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Privacy Level: $label', style: TextStyle(
fontSize: 16, fontWeight: FontWeight.w700, color: AppTheme.navyBlue,
)),
const SizedBox(height: 4),
Text(
'Your data is encrypted. Adjust settings below to control who sees what.',
style: TextStyle(fontSize: 12, color: SojornColors.textDisabled, height: 1.4),
),
],
),
),
],
),
);
}
}
// Section Title
class _SectionTitle extends StatelessWidget {
final String title;
const _SectionTitle({required this.title});
@override
Widget build(BuildContext context) {
return Text(title, style: TextStyle(
fontSize: 14, fontWeight: FontWeight.w700,
color: AppTheme.navyBlue.withValues(alpha: 0.6),
letterSpacing: 0.5,
));
}
}
// Toggle Tile
class _ToggleTile extends StatelessWidget {
final IconData icon;
final String title;
final String subtitle;
final bool value;
final ValueChanged<bool> onChanged;
const _ToggleTile({
required this.icon, required this.title,
required this.subtitle, required this.value, required this.onChanged,
});
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.only(bottom: 6),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: AppTheme.cardSurface,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: AppTheme.navyBlue.withValues(alpha: 0.06)),
),
child: Row(
children: [
Icon(icon, size: 20, color: AppTheme.navyBlue.withValues(alpha: 0.5)),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600)),
Text(subtitle, style: TextStyle(fontSize: 11, color: SojornColors.textDisabled)),
],
),
),
Switch.adaptive(
value: value,
onChanged: onChanged,
activeTrackColor: AppTheme.navyBlue,
),
],
),
);
}
}
// Choice Tile (segmented)
class _ChoiceTile extends StatelessWidget {
final IconData icon;
final String title;
final String value;
final Map<String, String> options;
final ValueChanged<String> onChanged;
const _ChoiceTile({
required this.icon, required this.title,
required this.value, required this.options, required this.onChanged,
});
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.only(bottom: 6),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: AppTheme.cardSurface,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: AppTheme.navyBlue.withValues(alpha: 0.06)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(icon, size: 20, color: AppTheme.navyBlue.withValues(alpha: 0.5)),
const SizedBox(width: 12),
Text(title, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600)),
],
),
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: SegmentedButton<String>(
segments: options.entries.map((e) => ButtonSegment(
value: e.key,
label: Text(e.value, style: const TextStyle(fontSize: 11)),
)).toList(),
selected: {value},
onSelectionChanged: (s) => onChanged(s.first),
style: ButtonStyle(
visualDensity: VisualDensity.compact,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
),
),
],
),
);
}
}
// Info Tile (read-only with badge)
class _InfoTile extends StatelessWidget {
final IconData icon;
final String title;
final String subtitle;
final String badge;
final Color badgeColor;
const _InfoTile({
required this.icon, required this.title,
required this.subtitle, required this.badge, required this.badgeColor,
});
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.only(bottom: 6),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
decoration: BoxDecoration(
color: AppTheme.cardSurface,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: AppTheme.navyBlue.withValues(alpha: 0.06)),
),
child: Row(
children: [
Icon(icon, size: 20, color: badgeColor),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600)),
Text(subtitle, style: TextStyle(fontSize: 11, color: SojornColors.textDisabled)),
],
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: badgeColor.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
),
child: Text(badge, style: TextStyle(
fontSize: 11, fontWeight: FontWeight.w700, color: badgeColor,
)),
),
],
),
);
}
}

View file

@ -13,7 +13,6 @@ import '../../services/image_upload_service.dart';
import '../../services/notification_service.dart';
import '../../theme/app_theme.dart';
import '../../theme/tokens.dart';
import 'privacy_dashboard_screen.dart';
import '../../widgets/app_scaffold.dart';
import '../../widgets/media/signed_media_image.dart';
import '../../widgets/sojorn_input.dart';
@ -173,13 +172,6 @@ class _ProfileSettingsScreenState extends ConsumerState<ProfileSettingsScreen> {
title: 'Privacy Gates',
onTap: () => _showPrivacyEditor(),
),
_buildEditTile(
icon: Icons.dashboard_outlined,
title: 'Privacy Dashboard',
onTap: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const PrivacyDashboardScreen()),
),
),
],
),
const SizedBox(height: AppTheme.spacingLg),

View file

@ -24,8 +24,6 @@ import '../../services/secure_chat_service.dart';
import '../post/post_detail_screen.dart';
import 'profile_settings_screen.dart';
import 'followers_following_screen.dart';
import '../../widgets/harmony_explainer_modal.dart';
import '../../widgets/follow_button.dart';
/// Unified profile screen - handles both own profile and viewing others.
///
@ -71,8 +69,6 @@ class _UnifiedProfileScreenState extends ConsumerState<UnifiedProfileScreen>
bool _isCreatingProfile = false;
ProfilePrivacySettings? _privacySettings;
bool _isPrivacyLoading = false;
List<Map<String, dynamic>> _mutualFollowers = [];
bool _isMutualFollowersLoading = false;
/// True when no handle was provided (bottom-nav profile tab)
bool get _isOwnProfileMode => widget.handle == null;
@ -477,13 +473,13 @@ class _UnifiedProfileScreenState extends ConsumerState<UnifiedProfileScreen>
}
});
} else {
await apiService.followUser(_profile!.id);
final status = await apiService.followUser(_profile!.id);
if (!mounted) return;
setState(() {
_followStatus = 'accepted';
_isFollowing = true;
_followStatus = status;
_isFollowing = status == 'accepted';
_isFriend = _isFollowing && _isFollowedBy;
if (_stats != null) {
if (_stats != null && _isFollowing) {
_stats = ProfileStats(
posts: _stats!.posts,
followers: _stats!.followers + 1,
@ -1279,9 +1275,7 @@ class _UnifiedProfileScreenState extends ConsumerState<UnifiedProfileScreen>
}
Widget _buildTrustInfo(TrustState trustState) {
return GestureDetector(
onTap: () => HarmonyExplainerModal.show(context, trustState),
child: Container(
return Container(
padding: const EdgeInsets.all(AppTheme.spacingMd),
decoration: BoxDecoration(
color: AppTheme.cardSurface,
@ -1338,7 +1332,6 @@ class _UnifiedProfileScreenState extends ConsumerState<UnifiedProfileScreen>
),
],
),
),
);
}

View file

@ -1,954 +0,0 @@
import 'dart:async';
import 'dart:io';
import 'package:camera/camera.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:sojorn/services/video_stitching_service.dart';
import 'package:video_player/video_player.dart';
import '../../../theme/tokens.dart';
import '../../../theme/app_theme.dart';
import '../../audio/audio_library_screen.dart';
import 'quip_preview_screen.dart';
class EnhancedQuipRecorderScreen extends StatefulWidget {
const EnhancedQuipRecorderScreen({super.key});
@override
State<EnhancedQuipRecorderScreen> createState() => _EnhancedQuipRecorderScreenState();
}
class _EnhancedQuipRecorderScreenState extends State<EnhancedQuipRecorderScreen>
with WidgetsBindingObserver {
// Config
static const Duration _maxDuration = Duration(seconds: 60); // Increased for multi-segment
// Camera State
CameraController? _cameraController;
List<CameraDescription> _cameras = [];
bool _isRearCamera = true;
bool _isInitializing = true;
bool _flashOn = false;
// Recording State
bool _isRecording = false;
bool _isPaused = false;
final List<File> _recordedSegments = [];
final List<Duration> _segmentDurations = [];
// Timer State
DateTime? _segmentStartTime;
Timer? _progressTicker;
Duration _currentSegmentDuration = Duration.zero;
// Speed Control
double _playbackSpeed = 1.0;
final List<double> _speedOptions = [0.5, 1.0, 2.0, 3.0];
// Effects and Filters
String _selectedFilter = 'none';
final List<String> _filters = ['none', 'grayscale', 'sepia', 'vintage', 'cold', 'warm', 'dramatic'];
// Text Overlay
bool _showTextOverlay = false;
String _overlayText = '';
double _textSize = 24.0;
Color _textColor = Colors.white;
double _textPositionY = 0.8; // 0=top, 1=bottom
// Audio Overlay
AudioTrack? _selectedAudio;
double _audioVolume = 0.5;
// Processing State
bool _isProcessing = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_initCamera();
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_progressTicker?.cancel();
_cameraController?.dispose();
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (_cameraController == null || !_cameraController!.value.isInitialized) return;
if (state == AppLifecycleState.inactive) {
_cameraController?.dispose();
} else if (state == AppLifecycleState.resumed) {
_initCamera();
}
}
Future<void> _initCamera() async {
setState(() => _isInitializing = true);
final status = await [Permission.camera, Permission.microphone].request();
if (status[Permission.camera] != PermissionStatus.granted ||
status[Permission.microphone] != PermissionStatus.granted) {
if(mounted) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Permissions denied')));
Navigator.pop(context);
}
return;
}
try {
_cameras = await availableCameras();
if (_cameras.isEmpty) throw Exception('No cameras found');
final camera = _cameras.firstWhere(
(c) => c.lensDirection == (_isRearCamera ? CameraLensDirection.back : CameraLensDirection.front),
orElse: () => _cameras.first
);
_cameraController = CameraController(
camera,
ResolutionPreset.high,
enableAudio: true,
imageFormatGroup: ImageFormatGroup.yuv420,
);
await _cameraController!.initialize();
setState(() => _isInitializing = false);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Camera initialization failed')));
Navigator.pop(context);
}
}
}
Duration get _totalRecordedDuration {
Duration total = Duration.zero;
for (final duration in _segmentDurations) {
total += duration;
}
return total + _currentSegmentDuration;
}
// Enhanced recording methods
Future<void> _startRecording() async {
if (_cameraController == null || !_cameraController!.value.isInitialized) return;
if (_totalRecordedDuration >= _maxDuration) return;
if (_isPaused) {
_resumeRecording();
return;
}
try {
await _cameraController!.startVideoRecording();
setState(() {
_isRecording = true;
_segmentStartTime = DateTime.now();
_currentSegmentDuration = Duration.zero;
});
_progressTicker = Timer.periodic(const Duration(milliseconds: 100), (timer) {
if (_segmentStartTime != null) {
setState(() {
_currentSegmentDuration = DateTime.now().difference(_segmentStartTime!);
});
}
});
// Auto-stop at max duration
Timer(const Duration(milliseconds: 100), () {
if (_totalRecordedDuration >= _maxDuration) {
_stopRecording();
}
});
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Failed to start recording')));
}
}
}
Future<void> _pauseRecording() async {
if (!_isRecording || _isPaused) return;
try {
await _cameraController!.pauseVideoRecording();
setState(() => _isPaused = true);
_progressTicker?.cancel();
// Save current segment
_segmentDurations.add(_currentSegmentDuration);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Failed to pause recording')));
}
}
}
Future<void> _resumeRecording() async {
if (!_isRecording || !_isPaused) return;
try {
await _cameraController!.resumeVideoRecording();
setState(() {
_isPaused = false;
_segmentStartTime = DateTime.now();
_currentSegmentDuration = Duration.zero;
});
_progressTicker = Timer.periodic(const Duration(milliseconds: 100), (timer) {
if (_segmentStartTime != null) {
setState(() {
_currentSegmentDuration = DateTime.now().difference(_segmentStartTime!);
});
}
});
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Failed to resume recording')));
}
}
}
Future<void> _stopRecording() async {
if (!_isRecording) return;
_progressTicker?.cancel();
try {
final videoFile = await _cameraController!.stopVideoRecording();
if (videoFile != null) {
setState(() => _isRecording = false);
_isPaused = false;
// Add segment if it has content
if (_currentSegmentDuration.inMilliseconds > 500) { // Minimum 0.5 seconds
_recordedSegments.add(File(videoFile.path));
_segmentDurations.add(_currentSegmentDuration);
}
// Auto-process if we have segments
if (_recordedSegments.isNotEmpty) {
_processVideo();
}
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Failed to stop recording')));
}
}
}
Future<void> _processVideo() async {
if (_recordedSegments.isEmpty || _isProcessing) return;
setState(() => _isProcessing = true);
try {
final finalFile = await VideoStitchingService.stitchVideos(
_recordedSegments,
_segmentDurations,
_selectedFilter,
_playbackSpeed,
_showTextOverlay ? {
'text': _overlayText,
'size': _textSize,
'color': '#${_textColor.value.toRadixString(16).padLeft(8, '0')}',
'position': _textPositionY,
} : null,
audioOverlayPath: _selectedAudio?.path,
audioVolume: _audioVolume,
);
if (finalFile != null && mounted) {
await _cameraController?.pausePreview();
// Navigate to enhanced preview
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => EnhancedQuipPreviewScreen(
videoFile: finalFile!,
segments: _recordedSegments,
durations: _segmentDurations,
filter: _selectedFilter,
speed: _playbackSpeed,
textOverlay: _showTextOverlay ? {
'text': _overlayText,
'size': _textSize,
'color': _textColor,
'position': _textPositionY,
} : null,
),
),
).then((_) {
_cameraController?.resumePreview();
});
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Video processing failed')));
}
} finally {
setState(() => _isProcessing = false);
}
}
void _toggleCamera() async {
if (_cameras.length < 2) return;
setState(() {
_isRearCamera = !_isRearCamera;
_isInitializing = true;
});
try {
final camera = _cameras.firstWhere(
(c) => c.lensDirection == (_isRearCamera ? CameraLensDirection.back : CameraLensDirection.front),
orElse: () => _cameras.first
);
await _cameraController?.dispose();
_cameraController = CameraController(
camera,
ResolutionPreset.high,
enableAudio: true,
imageFormatGroup: ImageFormatGroup.yuv420,
);
await _cameraController!.initialize();
setState(() => _isInitializing = false);
} catch (e) {
setState(() => _isInitializing = false);
}
}
void _toggleFlash() async {
if (_cameraController == null) return;
try {
if (_flashOn) {
await _cameraController!.setFlashMode(FlashMode.off);
} else {
await _cameraController!.setFlashMode(FlashMode.torch);
}
setState(() => _flashOn = !_flashOn);
} catch (e) {
// Flash not supported
}
}
void _clearSegments() {
setState(() {
_recordedSegments.clear();
_segmentDurations.clear();
_currentSegmentDuration = Duration.zero;
});
}
Future<void> _pickAudio() async {
final result = await Navigator.push<AudioTrack>(
context,
MaterialPageRoute(builder: (_) => const AudioLibraryScreen()),
);
if (result != null && mounted) {
setState(() => _selectedAudio = result);
}
}
@override
Widget build(BuildContext context) {
if (_isInitializing) {
return const Scaffold(
backgroundColor: Colors.black,
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(color: Colors.white),
const SizedBox(height: 16),
Text('Initializing camera...', style: TextStyle(color: Colors.white)),
],
),
),
);
}
return Scaffold(
backgroundColor: Colors.black,
body: Stack(
children: [
// Camera preview
if (_cameraController != null && _cameraController!.value.isInitialized)
Positioned.fill(
child: CameraPreview(_cameraController!),
),
// Controls overlay
Positioned.fill(
child: Column(
children: [
// Top controls
Expanded(
flex: 1,
child: Column(
mainAxisAlignment: MainAxisAlignment.end,
children: [
// Speed control
if (_isRecording || _recordedSegments.isNotEmpty)
Container(
margin: const EdgeInsets.all(16),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: _speedOptions.map((speed) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: GestureDetector(
onTap: () => setState(() => _playbackSpeed = speed),
child: Text(
'${speed}x',
style: TextStyle(
color: _playbackSpeed == speed ? AppTheme.navyBlue : Colors.white,
fontWeight: _playbackSpeed == speed ? FontWeight.bold : FontWeight.normal,
),
),
),
)).toList(),
),
),
// Filter selector
if (_isRecording || _recordedSegments.isNotEmpty)
Container(
margin: const EdgeInsets.all(16),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.circular(20),
),
child: Wrap(
spacing: 8,
children: _filters.map((filter) => GestureDetector(
onTap: () => setState(() => _selectedFilter = filter),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: _selectedFilter == filter ? AppTheme.navyBlue : Colors.transparent,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.white24),
),
child: Text(
filter,
style: TextStyle(
color: _selectedFilter == filter ? Colors.white : Colors.white70,
fontSize: 12,
),
),
),
)).toList(),
),
),
],
),
),
// Bottom controls
Container(
padding: const EdgeInsets.all(16),
child: Column(
children: [
// Progress bar
if (_isRecording || _isPaused)
Container(
margin: const EdgeInsets.only(bottom: 16),
child: LinearProgressIndicator(
value: _totalRecordedDuration.inMilliseconds / _maxDuration.inMilliseconds,
backgroundColor: Colors.white24,
valueColor: AlwaysStoppedAnimation<Color>(
_isPaused ? Colors.orange : Colors.red,
),
),
),
// Duration and controls
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
// Duration
Text(
_formatDuration(_totalRecordedDuration),
style: const TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
// Pause/Resume button
if (_isRecording)
GestureDetector(
onTap: _isPaused ? _resumeRecording : _pauseRecording,
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: _isPaused ? Colors.orange : Colors.red,
shape: BoxShape.circle,
),
child: Icon(
_isPaused ? Icons.play_arrow : Icons.pause,
color: Colors.white,
size: 24,
),
),
),
// Stop button
if (_isRecording)
GestureDetector(
onTap: _stopRecording,
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
),
child: const Icon(
Icons.stop,
color: Colors.black,
size: 24,
),
),
),
// Clear segments button
if (_recordedSegments.isNotEmpty && !_isRecording)
GestureDetector(
onTap: _clearSegments,
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.grey[700],
shape: BoxShape.circle,
),
child: const Icon(
Icons.delete_outline,
color: Colors.white,
size: 24,
),
),
),
// Record button
if (!_isRecording)
GestureDetector(
onLongPress: _startRecording,
onTap: _startRecording,
child: Container(
width: 80,
height: 80,
decoration: BoxDecoration(
color: Colors.red,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: Colors.red.withOpacity(0.3),
blurRadius: 20,
spreadRadius: 5,
),
],
),
child: const Icon(
Icons.videocam,
color: Colors.white,
size: 32,
),
),
),
],
),
// Audio track chip (shown when audio is selected)
if (_selectedAudio != null && !_isRecording)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Chip(
backgroundColor: Colors.deepPurple.shade700,
avatar: const Icon(Icons.music_note, color: Colors.white, size: 16),
label: Text(
_selectedAudio!.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.white, fontSize: 12),
),
deleteIcon: const Icon(Icons.close, color: Colors.white70, size: 16),
onDeleted: () => setState(() => _selectedAudio = null),
),
const SizedBox(width: 8),
SizedBox(
width: 80,
child: Slider(
value: _audioVolume,
min: 0.0,
max: 1.0,
activeColor: Colors.deepPurple.shade300,
inactiveColor: Colors.white24,
onChanged: (v) => setState(() => _audioVolume = v),
),
),
],
),
),
// Additional controls row
if (_recordedSegments.isNotEmpty && !_isRecording)
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
// Text overlay toggle
GestureDetector(
onTap: () => setState(() => _showTextOverlay = !_showTextOverlay),
child: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: _showTextOverlay ? AppTheme.navyBlue : Colors.white24,
shape: BoxShape.circle,
),
child: Icon(
Icons.text_fields,
color: _showTextOverlay ? Colors.white : Colors.white70,
size: 20,
),
),
),
// Music / audio overlay button
GestureDetector(
onTap: _pickAudio,
child: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: _selectedAudio != null ? Colors.deepPurple : Colors.white24,
shape: BoxShape.circle,
),
child: Icon(
Icons.music_note,
color: _selectedAudio != null ? Colors.white : Colors.white70,
size: 20,
),
),
),
// Camera toggle
GestureDetector(
onTap: _toggleCamera,
child: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.white24,
shape: BoxShape.circle,
),
child: Icon(
_isRearCamera ? Icons.camera_rear : Icons.camera_front,
color: Colors.white,
size: 20,
),
),
),
// Flash toggle
GestureDetector(
onTap: _toggleFlash,
child: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: _flashOn ? Colors.yellow : Colors.white24,
shape: BoxShape.circle,
),
child: Icon(
_flashOn ? Icons.flash_on : Icons.flash_off,
color: (_flashOn ? Colors.black : Colors.white),
size: 20,
),
),
),
],
),
],
),
),
],
),
),
// Text overlay editor (shown when enabled)
if (_showTextOverlay && !_isRecording)
Positioned(
bottom: 100,
left: 16,
right: 16,
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
style: const TextStyle(color: Colors.white),
decoration: const InputDecoration(
hintText: 'Add text overlay...',
hintStyle: TextStyle(color: Colors.white70),
border: InputBorder.none,
),
onChanged: (value) => setState(() => _overlayText = value),
),
const SizedBox(height: 8),
Row(
children: [
// Size selector
Expanded(
child: Slider(
value: _textSize,
min: 12,
max: 48,
divisions: 4,
label: '${_textSize.toInt()}',
activeColor: AppTheme.navyBlue,
inactiveColor: Colors.white24,
onChanged: (value) => setState(() => _textSize = value),
),
),
const SizedBox(width: 16),
// Position selector
Expanded(
child: Slider(
value: _textPositionY,
min: 0.0,
max: 1.0,
label: _textPositionY == 0.0 ? 'Top' : 'Bottom',
activeColor: AppTheme.navyBlue,
inactiveColor: Colors.white24,
onChanged: (value) => setState(() => _textPositionY = value),
),
),
],
),
const SizedBox(height: 8),
// Color picker
Row(
children: [
_buildColorButton(Colors.white),
_buildColorButton(Colors.black),
_buildColorButton(Colors.red),
_buildColorButton(Colors.blue),
_buildColorButton(Colors.green),
_buildColorButton(Colors.yellow),
],
),
],
),
),
),
// Processing overlay
if (_isProcessing)
Container(
color: Colors.black87,
child: const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(color: Colors.white),
SizedBox(height: 16),
Text('Processing video...', style: TextStyle(color: Colors.white)),
Text('Applying effects and stitching segments...', style: TextStyle(color: Colors.white70)),
],
),
),
),
],
),
);
}
Widget _buildColorButton(Color color) {
return GestureDetector(
onTap: () => setState(() => _textColor = color),
child: Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
border: _textColor == color ? Border.all(color: Colors.white) : null,
),
),
);
}
String _formatDuration(Duration duration) {
final minutes = duration.inMinutes;
final seconds = duration.inSeconds % 60;
return '${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
}
}
// Enhanced preview screen
class EnhancedQuipPreviewScreen extends StatefulWidget {
final File videoFile;
final List<File> segments;
final List<Duration> durations;
final String filter;
final double speed;
final Map<String, dynamic>? textOverlay;
const EnhancedQuipPreviewScreen({
super.key,
required this.videoFile,
required this.segments,
required this.durations,
this.filter = 'none',
this.speed = 1.0,
this.textOverlay,
});
@override
State<EnhancedQuipPreviewScreen> createState() => _EnhancedQuipPreviewScreenState();
}
class _EnhancedQuipPreviewScreenState extends State<EnhancedQuipPreviewScreen> {
late VideoPlayerController _videoController;
bool _isPlaying = false;
bool _isLoading = true;
@override
void initState() {
super.initState();
_initializePlayer();
}
@override
void dispose() {
_videoController.dispose();
super.dispose();
}
Future<void> _initializePlayer() async {
_videoController = VideoPlayerController.file(widget.videoFile);
_videoController.addListener(() {
if (mounted) {
setState(() {});
}
});
await _videoController.initialize();
setState(() => _isLoading = false);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
appBar: AppBar(
backgroundColor: Colors.black,
title: const Text('Preview', style: TextStyle(color: Colors.white)),
iconTheme: const IconThemeData(color: Colors.white),
actions: [
IconButton(
icon: Icon(_isPlaying ? Icons.pause : Icons.play_arrow),
onPressed: () {
setState(() {
_isPlaying = !_isPlaying;
});
if (_isPlaying) {
_videoController.pause();
} else {
_videoController.play();
}
},
),
IconButton(
icon: const Icon(Icons.check),
onPressed: () {
// Return to recorder with the processed video
Navigator.pop(context, widget.videoFile);
},
),
],
),
body: Center(
child: _isLoading
? const CircularProgressIndicator(color: Colors.white)
: Stack(
children: [
VideoPlayer(_videoController),
// Text overlay
if (widget.textOverlay != null)
Positioned(
bottom: 50 + (widget.textOverlay!['position'] as double) * 300,
left: 16,
right: 16,
child: Text(
widget.textOverlay!['text'],
style: TextStyle(
color: Color(int.parse(widget.textOverlay!['color'])),
fontSize: widget.textOverlay!['size'] as double,
fontWeight: FontWeight.bold,
),
),
),
// Controls overlay
Positioned(
bottom: 16,
left: 16,
right: 16,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton(
icon: Icon(_isPlaying ? Icons.pause : Icons.play_arrow),
onPressed: () {
setState(() {
_isPlaying = !_isPlaying;
});
if (_isPlaying) {
_videoController.pause();
} else {
_videoController.play();
}
},
),
Text(
'${widget.filter}${widget.speed}x',
style: const TextStyle(color: Colors.white),
),
],
),
),
],
),
),
);
}
}

View file

@ -1,385 +0,0 @@
import 'dart:async';
import 'dart:io';
import 'package:camera/camera.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:permission_handler/permission_handler.dart';
import '../../../screens/audio/audio_library_screen.dart';
import '../../../theme/tokens.dart';
import '../../../theme/app_theme.dart';
import 'quip_decorate_screen.dart';
/// Stage 1 of the new Quip creation flow.
///
/// Full-screen camera preview with:
/// - Pre-record sound selection (top-center)
/// - Flash + flip camera controls (top-right)
/// - 10 s progress-ring record button (bottom-center)
/// Tap = start/stop toggle; Hold = hold-to-record
///
/// On stop (or auto-stop at 10 s), navigates to [QuipDecorateScreen].
class QuipCameraScreen extends StatefulWidget {
const QuipCameraScreen({super.key});
@override
State<QuipCameraScreen> createState() => _QuipCameraScreenState();
}
class _QuipCameraScreenState extends State<QuipCameraScreen>
with WidgetsBindingObserver {
static const Duration _maxDuration = Duration(seconds: 10);
static const Duration _tickInterval = Duration(milliseconds: 30);
// Camera
List<CameraDescription> _cameras = [];
CameraController? _cameraController;
bool _isRearCamera = true;
bool _isInitializing = true;
bool _flashOn = false;
// Recording
bool _isRecording = false;
double _progress = 0.0; // 0.01.0
Timer? _progressTicker;
Timer? _autoStopTimer;
DateTime? _recordStart;
// Pre-record audio
AudioTrack? _selectedAudio;
// Processing (brief moment between stop and navigate)
bool _isProcessing = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_initCamera();
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_progressTicker?.cancel();
_autoStopTimer?.cancel();
_cameraController?.dispose();
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (_cameraController == null ||
!_cameraController!.value.isInitialized) return;
if (state == AppLifecycleState.inactive) {
_cameraController?.dispose();
} else if (state == AppLifecycleState.resumed) {
_initCamera();
}
}
// Camera init
Future<void> _initCamera() async {
setState(() => _isInitializing = true);
if (!kIsWeb) {
final status =
await [Permission.camera, Permission.microphone].request();
if (status[Permission.camera] != PermissionStatus.granted ||
status[Permission.microphone] != PermissionStatus.granted) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Camera & microphone access required')),
);
Navigator.pop(context);
}
return;
}
}
try {
_cameras = await availableCameras();
if (_cameras.isEmpty) throw Exception('No cameras found');
final camera = _cameras.firstWhere(
(c) => c.lensDirection ==
(_isRearCamera
? CameraLensDirection.back
: CameraLensDirection.front),
orElse: () => _cameras.first,
);
_cameraController = CameraController(
camera,
ResolutionPreset.high,
enableAudio: true,
imageFormatGroup: ImageFormatGroup.yuv420,
);
await _cameraController!.initialize();
await _cameraController!.prepareForVideoRecording();
if (mounted) setState(() => _isInitializing = false);
} catch (e) {
if (mounted) setState(() => _isInitializing = false);
}
}
Future<void> _toggleCamera() async {
if (_isRecording) return;
setState(() {
_isRearCamera = !_isRearCamera;
_isInitializing = true;
});
await _cameraController?.dispose();
_cameraController = null;
_initCamera();
}
Future<void> _toggleFlash() async {
if (_cameraController == null) return;
try {
_flashOn = !_flashOn;
await _cameraController!
.setFlashMode(_flashOn ? FlashMode.torch : FlashMode.off);
setState(() {});
} catch (_) {}
}
// Audio
Future<void> _pickSound() async {
final track = await Navigator.push<AudioTrack>(
context,
MaterialPageRoute(builder: (_) => const AudioLibraryScreen()),
);
if (track != null && mounted) {
setState(() => _selectedAudio = track);
}
}
// Recording
Future<void> _startRecording() async {
if (_cameraController == null ||
!_cameraController!.value.isInitialized ||
_isRecording) return;
try {
await _cameraController!.startVideoRecording();
_recordStart = DateTime.now();
_autoStopTimer = Timer(_maxDuration, _stopRecording);
_progressTicker =
Timer.periodic(_tickInterval, (_) => _updateProgress());
if (mounted) setState(() => _isRecording = true);
} catch (_) {}
}
void _updateProgress() {
if (!mounted || _recordStart == null) return;
final elapsed = DateTime.now().difference(_recordStart!);
setState(() {
_progress =
(elapsed.inMilliseconds / _maxDuration.inMilliseconds).clamp(0.0, 1.0);
});
}
Future<void> _stopRecording() async {
if (!_isRecording) return;
_progressTicker?.cancel();
_autoStopTimer?.cancel();
try {
final xfile = await _cameraController!.stopVideoRecording();
if (!mounted) return;
setState(() {
_isRecording = false;
_progress = 0.0;
_isProcessing = true;
});
await _cameraController?.pausePreview();
final videoFile = File(xfile.path);
if (mounted) {
await Navigator.push(
context,
MaterialPageRoute(
builder: (_) => QuipDecorateScreen(
videoFile: videoFile,
preloadedAudio: _selectedAudio,
),
),
);
await _cameraController?.resumePreview();
if (mounted) setState(() => _isProcessing = false);
}
} catch (_) {
if (mounted) setState(() {_isRecording = false; _progress = 0.0; _isProcessing = false;});
}
}
void _onRecordTap() {
if (_isRecording) {
_stopRecording();
} else {
_startRecording();
}
}
// Build
@override
Widget build(BuildContext context) {
if (_isInitializing || _cameraController == null) {
return const Scaffold(
backgroundColor: SojornColors.basicBlack,
body: Center(child: CircularProgressIndicator(color: SojornColors.basicWhite)),
);
}
return Scaffold(
backgroundColor: SojornColors.basicBlack,
body: Stack(
fit: StackFit.expand,
children: [
// Full-screen camera preview
CameraPreview(_cameraController!),
// Processing overlay
if (_isProcessing)
const ColoredBox(
color: Color(0x88000000),
child: Center(child: CircularProgressIndicator(color: SojornColors.basicWhite)),
),
// Top bar
SafeArea(
child: Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Close
IconButton(
icon: const Icon(Icons.close, color: SojornColors.basicWhite),
onPressed: () => Navigator.pop(context),
),
// Add Sound (center)
Expanded(
child: Center(
child: GestureDetector(
onTap: _pickSound,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 7),
decoration: BoxDecoration(
border: Border.all(color: SojornColors.basicWhite.withValues(alpha: 0.7)),
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.music_note, color: SojornColors.basicWhite, size: 16),
const SizedBox(width: 6),
Text(
_selectedAudio != null
? _selectedAudio!.title
: 'Add Sound',
style: const TextStyle(
color: SojornColors.basicWhite,
fontSize: 13,
fontWeight: FontWeight.w500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
),
),
),
// Flash + Flip
Row(
children: [
IconButton(
icon: Icon(
_flashOn ? Icons.flash_on : Icons.flash_off,
color: SojornColors.basicWhite,
),
onPressed: _toggleFlash,
),
IconButton(
icon: const Icon(Icons.flip_camera_ios, color: SojornColors.basicWhite),
onPressed: _toggleCamera,
),
],
),
],
),
),
],
),
),
// Record button (bottom-center)
Positioned(
bottom: 56,
left: 0,
right: 0,
child: Center(child: _buildRecordButton()),
),
],
),
);
}
Widget _buildRecordButton() {
return GestureDetector(
onTap: _onRecordTap,
onLongPress: _startRecording,
onLongPressUp: _stopRecording,
child: SizedBox(
width: 88,
height: 88,
child: Stack(
alignment: Alignment.center,
children: [
// Progress ring
SizedBox(
width: 88,
height: 88,
child: CircularProgressIndicator(
value: _isRecording ? _progress : 0.0,
strokeWidth: 4,
backgroundColor: SojornColors.basicWhite.withValues(alpha: 0.3),
valueColor:
const AlwaysStoppedAnimation<Color>(SojornColors.destructive),
),
),
// Inner solid circle (slightly smaller)
Container(
width: 68,
height: 68,
decoration: BoxDecoration(
color: _isRecording
? SojornColors.destructive
: SojornColors.destructive,
shape: BoxShape.circle,
border: Border.all(
color: SojornColors.basicWhite,
width: _isRecording ? 0 : 3,
),
),
child: _isRecording
? const Icon(Icons.stop_rounded,
color: SojornColors.basicWhite, size: 32)
: null,
),
],
),
),
);
}
}

View file

@ -1,14 +1,13 @@
import 'package:flutter/material.dart';
import 'quip_camera_screen.dart';
import 'quip_recorder_screen.dart';
/// Entry point wrapper for the Quip Creation Flow.
/// Routes to [QuipCameraScreen] the new Snapchat-style camera with
/// instant sticker/text decoration and zero encoding wait.
/// Navigation is now handled linearly starting from [QuipRecorderScreen].
class QuipCreationFlow extends StatelessWidget {
const QuipCreationFlow({super.key});
@override
Widget build(BuildContext context) {
return const QuipCameraScreen();
return const QuipRecorderScreen();
}
}

View file

@ -1,593 +0,0 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:video_player/video_player.dart';
import '../../../models/quip_text_overlay.dart';
import '../../../providers/quip_upload_provider.dart';
import '../../../screens/audio/audio_library_screen.dart';
import '../../../theme/tokens.dart';
import '../../../theme/app_theme.dart';
// Curated sticker/emoji set for the picker
const _kTextStickers = ['LOL', 'OMG', 'WOW', 'WAIT', 'FR?', 'NO WAY'];
const _kEmojis = [
'🎉', '🔥', '❤️', '😂', '💯', '',
'🤣', '😍', '🙌', '😮', '💕', '🤩',
'🎶', '🌟', '💀', '😎', '🥰', '🤔',
'👀', '🫶',
];
// Colors available for text overlays
const _kTextColors = [
Colors.white,
Colors.yellow,
Colors.cyan,
Colors.pinkAccent,
Colors.greenAccent,
Colors.redAccent,
];
/// Stage 2 of the new Quip creation flow.
///
/// The raw video loops immediately. The user decorates with:
/// - Draggable + pinch-to-scale/rotate text and sticker overlays
/// - Pre-recorded or newly-selected background audio
/// - A "Post Quip" FAB that fires a background upload and returns to the feed
class QuipDecorateScreen extends ConsumerStatefulWidget {
final File videoFile;
final AudioTrack? preloadedAudio;
const QuipDecorateScreen({
super.key,
required this.videoFile,
this.preloadedAudio,
});
@override
ConsumerState<QuipDecorateScreen> createState() => _QuipDecorateScreenState();
}
class _QuipDecorateScreenState extends ConsumerState<QuipDecorateScreen> {
late VideoPlayerController _controller;
bool _videoReady = false;
// Overlays
final List<_EditableOverlay> _overlays = [];
String? _draggingId; // id of the item being dragged/scaled
// Trash zone
bool _showTrash = false;
bool _overTrash = false;
// Audio
AudioTrack? _selectedAudio;
// Text color for next text item
Color _nextTextColor = Colors.white;
@override
void initState() {
super.initState();
_selectedAudio = widget.preloadedAudio;
_initVideo();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
Future<void> _initVideo() async {
_controller = VideoPlayerController.file(widget.videoFile);
await _controller.initialize();
_controller.setLooping(true);
_controller.play();
if (mounted) setState(() => _videoReady = true);
}
// Overlay management
String _newId() => DateTime.now().microsecondsSinceEpoch.toString();
void _addTextOverlay(String text) {
if (text.trim().isEmpty) return;
setState(() {
_overlays.add(_EditableOverlay(
id: _newId(),
type: QuipOverlayType.text,
content: text.trim(),
color: _nextTextColor,
normalizedX: 0.5,
normalizedY: 0.4,
scale: 1.0,
rotation: 0.0,
));
});
}
void _addStickerOverlay(String sticker) {
setState(() {
_overlays.add(_EditableOverlay(
id: _newId(),
type: QuipOverlayType.sticker,
content: sticker,
color: Colors.white,
normalizedX: 0.5,
normalizedY: 0.5,
scale: 1.0,
rotation: 0.0,
));
});
}
void _removeOverlay(String id) {
setState(() => _overlays.removeWhere((o) => o.id == id));
}
// Actions
void _openTextSheet() {
final ctrl = TextEditingController();
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: const Color(0xDD000000),
builder: (ctx) => Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(ctx).viewInsets.bottom + 16,
left: 16,
right: 16,
top: 16,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Color row
Row(
children: _kTextColors.map((c) {
final selected = c == _nextTextColor;
return GestureDetector(
onTap: () => setState(() => _nextTextColor = c),
child: Container(
margin: const EdgeInsets.only(right: 8),
width: 28,
height: 28,
decoration: BoxDecoration(
color: c,
shape: BoxShape.circle,
border: selected
? Border.all(color: SojornColors.basicWhite, width: 2)
: null,
),
),
);
}).toList(),
),
const SizedBox(height: 12),
TextField(
controller: ctrl,
autofocus: true,
style: const TextStyle(color: SojornColors.basicWhite, fontSize: 22),
decoration: InputDecoration(
border: InputBorder.none,
hintText: 'Type something...',
hintStyle: TextStyle(color: SojornColors.basicWhite.withValues(alpha: 0.4)),
),
onSubmitted: (val) {
Navigator.pop(ctx);
_addTextOverlay(val);
},
),
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: () {
Navigator.pop(ctx);
_addTextOverlay(ctrl.text);
},
child: const Text('Done', style: TextStyle(color: SojornColors.basicWhite, fontSize: 16)),
),
),
],
),
),
);
}
void _openStickerSheet() {
showModalBottomSheet(
context: context,
backgroundColor: const Color(0xDD000000),
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (ctx) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Text stickers row
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Wrap(
spacing: 10,
runSpacing: 10,
children: _kTextStickers.map((s) {
return GestureDetector(
onTap: () {
Navigator.pop(ctx);
_addStickerOverlay(s);
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
decoration: BoxDecoration(
border: Border.all(color: SojornColors.basicWhite, width: 1.5),
borderRadius: BorderRadius.circular(8),
),
child: Text(s,
style: const TextStyle(
color: SojornColors.basicWhite,
fontWeight: FontWeight.bold,
fontSize: 14)),
),
);
}).toList(),
),
),
// Emoji grid
SizedBox(
height: 180,
child: GridView.count(
crossAxisCount: 7,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
children: _kEmojis.map((e) {
return GestureDetector(
onTap: () {
Navigator.pop(ctx);
_addStickerOverlay(e);
},
child: Center(
child: Text(e, style: const TextStyle(fontSize: 28)),
),
);
}).toList(),
),
),
],
),
),
);
}
Future<void> _pickSound() async {
final track = await Navigator.push<AudioTrack>(
context,
MaterialPageRoute(builder: (_) => const AudioLibraryScreen()),
);
if (track != null && mounted) {
setState(() => _selectedAudio = track);
}
}
Future<void> _postQuip() async {
_controller.pause();
// Build overlay + sound JSON payload
final payload = {
'overlays': _overlays.map((o) => o.toJson()).toList(),
if (_selectedAudio != null) 'sound_id': _selectedAudio!.path,
};
final overlayJson = jsonEncode(payload);
ref.read(quipUploadProvider.notifier).startUpload(
widget.videoFile,
'',
overlayJson: overlayJson,
);
if (mounted) {
Navigator.of(context).popUntil((route) => route.isFirst);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Uploading your Quip...')),
);
}
}
// Build
@override
Widget build(BuildContext context) {
if (!_videoReady) {
return const Scaffold(
backgroundColor: SojornColors.basicBlack,
body: Center(child: CircularProgressIndicator(color: SojornColors.basicWhite)),
);
}
return Scaffold(
backgroundColor: SojornColors.basicBlack,
body: LayoutBuilder(
builder: (context, constraints) {
final w = constraints.maxWidth;
final h = constraints.maxHeight;
return Stack(
fit: StackFit.expand,
children: [
// 1. Looping video
Center(
child: FittedBox(
fit: BoxFit.cover,
child: SizedBox(
width: _controller.value.size.width,
height: _controller.value.size.height,
child: VideoPlayer(_controller),
),
),
),
// 2. Overlay items (draggable, pinch-to-scale/rotate)
..._overlays.map((o) => _buildOverlayWidget(o, w, h)),
// 3. Trash zone (shown while dragging)
if (_showTrash)
Positioned(
bottom: 40,
left: 0,
right: 0,
child: Center(
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: _overTrash
? SojornColors.destructive
: const Color(0xAA000000),
shape: BoxShape.circle,
),
child: Icon(
Icons.delete_outline,
color: SojornColors.basicWhite,
size: _overTrash ? 40 : 32,
),
),
),
),
// 4. Top-left back button
SafeArea(
child: Align(
alignment: Alignment.topLeft,
child: IconButton(
icon: const Icon(Icons.arrow_back, color: SojornColors.basicWhite),
onPressed: () => Navigator.pop(context),
),
),
),
// 5. Right sidebar (Text, Sticker, Sound)
Positioned(
right: 16,
top: 100,
child: SafeArea(
child: Column(
children: [
_buildSideButton(Icons.text_fields, 'Text', _openTextSheet),
const SizedBox(height: 20),
_buildSideButton(Icons.emoji_emotions_outlined, 'Sticker', _openStickerSheet),
const SizedBox(height: 20),
_buildSideButton(
_selectedAudio != null ? Icons.music_note : Icons.music_note_outlined,
_selectedAudio != null ? 'Sound ✓' : 'Sound',
_pickSound,
),
],
),
),
),
// 6. "Post Quip" FAB (bottom-right)
Positioned(
bottom: 40,
right: 20,
child: FloatingActionButton.extended(
backgroundColor: AppTheme.brightNavy,
onPressed: _postQuip,
icon: const Icon(Icons.send_rounded, color: SojornColors.basicWhite),
label: const Text(
'Post Quip',
style: TextStyle(color: SojornColors.basicWhite, fontWeight: FontWeight.w600),
),
),
),
],
);
},
),
);
}
Widget _buildOverlayWidget(_EditableOverlay overlay, double w, double h) {
final absX = overlay.normalizedX * w;
final absY = overlay.normalizedY * h;
final isText = overlay.type == QuipOverlayType.text;
return Positioned(
left: absX - 60, // rough half-width offset so item centers on position
top: absY - 30,
child: GestureDetector(
onScaleStart: (_) {
setState(() {
_draggingId = overlay.id;
_showTrash = true;
});
},
onScaleUpdate: (details) {
final idx = _overlays.indexWhere((o) => o.id == overlay.id);
if (idx == -1) return;
// Convert global focal point to normalized position
final newNX = (details.focalPoint.dx / w).clamp(0.0, 1.0);
final newNY = (details.focalPoint.dy / h).clamp(0.0, 1.0);
// Detect if over trash zone (bottom 80px)
final overTrash = details.focalPoint.dy > h - 80;
setState(() {
_overTrash = overTrash;
_overlays[idx] = _overlays[idx].copyWith(
normalizedX: newNX,
normalizedY: newNY,
scale: (_overlays[idx].scale * details.scale).clamp(0.3, 5.0),
rotation: _overlays[idx].rotation + details.rotation,
);
});
},
onScaleEnd: (_) {
if (_overTrash && _draggingId != null) {
_removeOverlay(_draggingId!);
}
setState(() {
_draggingId = null;
_showTrash = false;
_overTrash = false;
});
},
child: Transform(
transform: Matrix4.identity()
..scale(overlay.scale)
..rotateZ(overlay.rotation),
alignment: Alignment.center,
child: isText
? _buildTextChip(overlay)
: _buildStickerChip(overlay),
),
),
);
}
Widget _buildTextChip(_EditableOverlay overlay) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(8),
),
child: Text(
overlay.content,
style: TextStyle(
color: overlay.color,
fontSize: 28,
fontWeight: FontWeight.bold,
shadows: const [Shadow(blurRadius: 4, color: Colors.black)],
),
),
);
}
Widget _buildStickerChip(_EditableOverlay overlay) {
final isEmoji = overlay.content.runes.length == 1 ||
overlay.content.length <= 2;
if (isEmoji) {
return Text(overlay.content, style: const TextStyle(fontSize: 48));
}
// Text sticker ('LOL', 'OMG', etc.)
return Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
decoration: BoxDecoration(
border: Border.all(color: SojornColors.basicWhite, width: 2),
borderRadius: BorderRadius.circular(8),
color: Colors.black.withValues(alpha: 0.3),
),
child: Text(
overlay.content,
style: const TextStyle(
color: SojornColors.basicWhite,
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
);
}
Widget _buildSideButton(IconData icon, String label, VoidCallback onTap) {
return Column(
children: [
GestureDetector(
onTap: onTap,
child: CircleAvatar(
backgroundColor: const Color(0x8A000000),
radius: 24,
child: Icon(icon, color: SojornColors.basicWhite, size: 26),
),
),
const SizedBox(height: 4),
Text(
label,
style: const TextStyle(
color: SojornColors.basicWhite,
fontSize: 11,
shadows: [Shadow(blurRadius: 2, color: Colors.black)],
),
),
],
);
}
}
// Internal mutable overlay state
class _EditableOverlay {
final String id;
final QuipOverlayType type;
final String content;
final Color color;
double normalizedX;
double normalizedY;
double scale;
double rotation;
_EditableOverlay({
required this.id,
required this.type,
required this.content,
required this.color,
required this.normalizedX,
required this.normalizedY,
required this.scale,
required this.rotation,
});
_EditableOverlay copyWith({
double? normalizedX,
double? normalizedY,
double? scale,
double? rotation,
}) {
return _EditableOverlay(
id: id,
type: type,
content: content,
color: color,
normalizedX: normalizedX ?? this.normalizedX,
normalizedY: normalizedY ?? this.normalizedY,
scale: scale ?? this.scale,
rotation: rotation ?? this.rotation,
);
}
Map<String, dynamic> toJson() => {
'id': id,
'type': type.name,
'content': content,
'color': color.value,
'position': {'x': normalizedX, 'y': normalizedY},
'scale': scale,
'rotation': rotation,
};
}

View file

@ -196,7 +196,7 @@ class _QuipRecorderScreenState extends State<QuipRecorderScreen>
if (_recordedSegments.length == 1) {
finalFile = _recordedSegments.first;
} else {
finalFile = await VideoStitchingService.stitchVideosLegacy(_recordedSegments);
finalFile = await VideoStitchingService.stitchVideos(_recordedSegments);
}
if (finalFile != null && mounted) {

File diff suppressed because it is too large Load diff

View file

@ -9,9 +9,9 @@ import '../../../providers/feed_refresh_provider.dart';
import '../../../routes/app_routes.dart';
import '../../../theme/app_theme.dart';
import '../../../theme/tokens.dart';
import '../../post/post_detail_screen.dart';
import 'quip_video_item.dart';
import '../../home/home_shell.dart';
import '../../../widgets/reactions/reaction_picker.dart';
import '../../../widgets/video_comments_sheet.dart';
class Quip {
@ -23,10 +23,7 @@ class Quip {
final String? displayName;
final String? avatarUrl;
final int? durationMs;
final int commentCount;
final String? overlayJson;
final Map<String, int> reactions;
final Set<String> myReactions;
final int? likeCount;
const Quip({
required this.id,
@ -37,10 +34,7 @@ class Quip {
this.displayName,
this.avatarUrl,
this.durationMs,
this.commentCount = 0,
this.overlayJson,
this.reactions = const {},
this.myReactions = const {},
this.likeCount,
});
factory Quip.fromMap(Map<String, dynamic> map) {
@ -58,29 +52,17 @@ class Quip {
displayName: author?['display_name'] as String?,
avatarUrl: author?['avatar_url'] as String?,
durationMs: map['duration_ms'] as int?,
commentCount: _parseCount(map['comment_count']),
overlayJson: map['overlay_json'] as String?,
reactions: _parseReactions(map['reactions']),
myReactions: _parseMyReactions(map['my_reactions']),
likeCount: _parseLikeCount(map['metrics']),
);
}
static Map<String, int> _parseReactions(dynamic v) {
if (v is Map<String, dynamic>) {
return v.map((k, val) => MapEntry(k, val is int ? val : (val is num ? val.toInt() : 0)));
static int? _parseLikeCount(dynamic metrics) {
if (metrics is Map<String, dynamic>) {
final val = metrics['like_count'];
if (val is int) return val;
if (val is num) return val.toInt();
}
return {};
}
static Set<String> _parseMyReactions(dynamic v) {
if (v is List) return v.whereType<String>().toSet();
return {};
}
static int _parseCount(dynamic v) {
if (v is int) return v;
if (v is num) return v.toInt();
return 0;
return null;
}
}
@ -101,8 +83,8 @@ class _QuipsFeedScreenState extends ConsumerState<QuipsFeedScreen>
final List<Quip> _quips = [];
final Map<int, VideoPlayerController> _controllers = {};
final Map<int, Future<void>> _controllerFutures = {};
final Map<String, Map<String, int>> _reactionCounts = {};
final Map<String, Set<String>> _myReactions = {};
final Map<String, bool> _liked = {};
final Map<String, int> _likeCounts = {};
bool _isLoading = false;
bool _hasMore = true;
@ -283,8 +265,7 @@ class _QuipsFeedScreenState extends ConsumerState<QuipsFeedScreen>
}
} else {
}
} catch (_) {
// Ignore initial post will just not appear at top
} catch (e) {
}
}
}
@ -313,10 +294,7 @@ class _QuipsFeedScreenState extends ConsumerState<QuipsFeedScreen>
_quips.addAll(items);
_hasMore = items.length == _pageSize;
for (final item in items) {
_reactionCounts.putIfAbsent(
item.id, () => Map<String, int>.from(item.reactions));
_myReactions.putIfAbsent(
item.id, () => Set<String>.from(item.myReactions));
_likeCounts.putIfAbsent(item.id, () => item.likeCount ?? 0);
}
});
@ -428,79 +406,41 @@ class _QuipsFeedScreenState extends ConsumerState<QuipsFeedScreen>
await _fetchQuips();
}
Future<void> _toggleReaction(Quip quip, String emoji) async {
Future<void> _toggleLike(Quip quip) async {
final api = ref.read(apiServiceProvider);
final currentCounts =
Map<String, int>.from(_reactionCounts[quip.id] ?? quip.reactions);
final currentMine =
Set<String>.from(_myReactions[quip.id] ?? quip.myReactions);
// Optimistic update
final isRemoving = currentMine.contains(emoji);
final currentlyLiked = _liked[quip.id] ?? false;
setState(() {
if (isRemoving) {
currentMine.remove(emoji);
final newCount = (currentCounts[emoji] ?? 1) - 1;
if (newCount <= 0) {
currentCounts.remove(emoji);
} else {
currentCounts[emoji] = newCount;
}
} else {
currentMine.add(emoji);
currentCounts[emoji] = (currentCounts[emoji] ?? 0) + 1;
}
_reactionCounts[quip.id] = currentCounts;
_myReactions[quip.id] = currentMine;
_liked[quip.id] = !currentlyLiked;
final currentCount = _likeCounts[quip.id] ?? 0;
final next = currentlyLiked ? currentCount - 1 : currentCount + 1;
_likeCounts[quip.id] = next < 0 ? 0 : next;
});
try {
await api.toggleReaction(quip.id, emoji);
if (currentlyLiked) {
await api.unappreciatePost(quip.id);
} else {
await api.appreciatePost(quip.id);
}
} catch (_) {
// Revert on failure
// revert on failure
if (!mounted) return;
setState(() {
_reactionCounts[quip.id] = Map<String, int>.from(quip.reactions);
_myReactions[quip.id] = Set<String>.from(quip.myReactions);
_liked[quip.id] = currentlyLiked;
_likeCounts[quip.id] =
(_likeCounts[quip.id] ?? 0) + (currentlyLiked ? 1 : -1);
if ((_likeCounts[quip.id] ?? 0) < 0) {
_likeCounts[quip.id] = 0;
}
});
}
}
void _openReactionPicker(Quip quip) {
showDialog(
context: context,
builder: (_) => ReactionPicker(
onReactionSelected: (emoji) => _toggleReaction(quip, emoji),
reactionCounts: _reactionCounts[quip.id] ?? quip.reactions,
myReactions: _myReactions[quip.id] ?? quip.myReactions,
),
);
}
Future<void> _handleNotInterested(Quip quip) async {
final index = _quips.indexOf(quip);
if (index == -1) return;
// Optimistic removal user sees it gone immediately
setState(() {
_quips.removeAt(index);
final ctrl = _controllers.remove(index);
ctrl?.dispose();
// Remap controllers above the removed index
final remapped = <int, VideoPlayerController>{};
_controllers.forEach((k, v) {
remapped[k > index ? k - 1 : k] = v;
});
_controllers
..clear()
..addAll(remapped);
if (_currentIndex >= _quips.length && _currentIndex > 0) {
_currentIndex = _quips.length - 1;
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Could not update like. Please try again.'),
),
);
}
});
// Fire-and-forget to backend no revert on failure (signal still valuable)
ref.read(apiServiceProvider).hidePost(quip.id).catchError((_) {});
}
}
Future<void> _openComments(Quip quip) async {
@ -510,9 +450,10 @@ class _QuipsFeedScreenState extends ConsumerState<QuipsFeedScreen>
backgroundColor: SojornColors.transparent,
builder: (context) => VideoCommentsSheet(
postId: quip.id,
initialCommentCount: quip.commentCount,
showNavActions: false,
onCommentPosted: () {},
initialCommentCount: 0,
onCommentPosted: () {
// Optional: handle reload if needed
},
),
);
}
@ -584,7 +525,8 @@ class _QuipsFeedScreenState extends ConsumerState<QuipsFeedScreen>
child: PageView.builder(
controller: _pageController,
scrollDirection: Axis.vertical,
physics: const PageScrollPhysics(parent: AlwaysScrollableScrollPhysics()),
// Ensure physics allows scrolling to trigger refresh
physics: const AlwaysScrollableScrollPhysics(),
itemCount: _quips.length,
onPageChanged: (index) {
_currentIndex = index;
@ -597,6 +539,8 @@ class _QuipsFeedScreenState extends ConsumerState<QuipsFeedScreen>
itemBuilder: (context, index) {
final quip = _quips[index];
final controller = _controllers[index];
final isLiked = _liked[quip.id] ?? false;
final likeCount = _likeCounts[quip.id] ?? quip.likeCount ?? 0;
return VisibilityDetector(
key: ValueKey('quip-${quip.id}'),
onVisibilityChanged: (info) =>
@ -605,16 +549,13 @@ class _QuipsFeedScreenState extends ConsumerState<QuipsFeedScreen>
quip: quip,
controller: controller,
isActive: index == _currentIndex,
reactions: _reactionCounts[quip.id] ?? quip.reactions,
myReactions: _myReactions[quip.id] ?? quip.myReactions,
commentCount: quip.commentCount,
isLiked: isLiked,
likeCount: likeCount,
isUserPaused: _isUserPaused,
onReact: (emoji) => _toggleReaction(quip, emoji),
onOpenReactionPicker: () => _openReactionPicker(quip),
onLike: () => _toggleLike(quip),
onComment: () => _openComments(quip),
onShare: () => _shareQuip(quip),
onTogglePause: _toggleUserPause,
onNotInterested: () => _handleNotInterested(quip),
),
);
},

View file

@ -833,7 +833,7 @@ class _ChatDataManagementScreenState extends State<ChatDataManagementScreen> {
);
if (confirmed != true) return;
await _e2ee.resetIdentityKeys();
await _e2ee.forceResetBrokenKeys();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Encryption keys reset. New identity generated.')),

View file

@ -151,89 +151,96 @@ class _SecureChatFullScreenState extends State<SecureChatFullScreen> {
showSearch: false,
showMessages: false,
leadingActions: [
PopupMenuButton<_ChatMenuAction>(
icon: Icon(Icons.more_vert, color: AppTheme.navyBlue),
tooltip: 'More options',
onSelected: (action) async {
switch (action) {
case _ChatMenuAction.refresh:
await _loadConversations();
case _ChatMenuAction.uploadKeys:
try {
await _chatService.uploadKeysManually();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Keys uploaded successfully'),
backgroundColor: Color(0xFF4CAF50),
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to upload keys: $e'),
backgroundColor: SojornColors.destructive,
),
);
}
}
case _ChatMenuAction.backup:
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const EncryptionHubScreen(),
IconButton(
onPressed: _loadConversations,
icon: Icon(Icons.refresh, color: AppTheme.navyBlue),
tooltip: 'Refresh conversations',
),
IconButton(
onPressed: () async {
try {
await _chatService.uploadKeysManually();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Keys uploaded successfully'),
backgroundColor: const Color(0xFF4CAF50),
),
);
case _ChatMenuAction.devices:
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Device management coming soon')),
SnackBar(
content: Text('Failed to upload keys: $e'),
backgroundColor: SojornColors.destructive,
),
);
}
}
},
itemBuilder: (_) => [
PopupMenuItem(
value: _ChatMenuAction.refresh,
child: Row(children: [
Icon(Icons.refresh, size: 18, color: AppTheme.navyBlue),
const SizedBox(width: 12),
const Text('Refresh'),
]),
),
PopupMenuItem(
value: _ChatMenuAction.uploadKeys,
child: Row(children: [
Icon(Icons.key, size: 18, color: AppTheme.navyBlue),
const SizedBox(width: 12),
const Text('Upload keys'),
]),
),
PopupMenuItem(
value: _ChatMenuAction.backup,
child: Row(children: [
Icon(Icons.backup, size: 18, color: AppTheme.navyBlue),
const SizedBox(width: 12),
const Text('Backup & Recovery'),
]),
),
PopupMenuItem(
value: _ChatMenuAction.devices,
child: Row(children: [
Icon(Icons.devices, size: 18, color: AppTheme.navyBlue),
const SizedBox(width: 12),
const Text('Device Management'),
]),
),
],
icon: Icon(Icons.key, color: AppTheme.navyBlue),
tooltip: 'Upload encryption keys',
),
],
body: _buildBody(),
floatingActionButton: FloatingActionButton(
onPressed: _showNewConversationSheet,
backgroundColor: AppTheme.brightNavy,
tooltip: 'New conversation',
child: const Icon(Icons.edit_outlined, color: Colors.white),
bottomNavigationBar: Container(
decoration: BoxDecoration(
color: AppTheme.scaffoldBg,
border: Border(
top: BorderSide(
color: AppTheme.navyBlue.withValues(alpha: 0.1),
width: 1,
),
),
),
child: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
Expanded(
child: ElevatedButton.icon(
onPressed: _showNewConversationSheet,
icon: Icon(Icons.add, size: 18),
label: Text('New Conversation'),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.brightNavy,
foregroundColor: SojornColors.basicWhite,
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
),
const SizedBox(width: 12),
IconButton(
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const EncryptionHubScreen(),
),
);
},
icon: Icon(Icons.backup, color: AppTheme.navyBlue),
tooltip: 'Backup & Recovery',
),
IconButton(
onPressed: () {
// TODO: Show device management
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Device management coming soon')),
);
},
icon: Icon(Icons.devices, color: AppTheme.navyBlue),
tooltip: 'Device Management',
),
],
),
),
),
),
);
}
@ -483,8 +490,6 @@ class _SecureChatFullScreenState extends State<SecureChatFullScreen> {
}
}
enum _ChatMenuAction { refresh, uploadKeys, backup, devices }
class _ConversationTile extends StatefulWidget {
final SecureConversation conversation;
final VoidCallback onTap;

View file

@ -1050,6 +1050,56 @@ class _SecureChatScreenState extends State<SecureChatScreen>
_chatService.markMessageLocallyDeleted(messageId);
}
Future<void> _forceResetBrokenKeys() async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Force Reset All Keys?'),
content: const Text(
'This will DELETE all encryption keys and generate fresh 256-bit keys. '
'This fixes the 208-bit key bug that causes MAC errors. '
'All existing messages will become undecryptable.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
style: TextButton.styleFrom(foregroundColor: SojornColors.destructive),
child: const Text('Force Reset'),
),
],
),
);
if (confirmed == true && mounted) {
try {
await _chatService.forceResetBrokenKeys();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Keys force reset! Restart chat to test.'),
backgroundColor: SojornColors.destructive,
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Error resetting keys: $e'),
backgroundColor: AppTheme.error,
),
);
}
}
}
}
Widget _buildInputArea() {
return ComposerWidget(
controller: _messageController,

View file

@ -84,7 +84,7 @@ class _EncryptionHubScreenState extends State<EncryptionHubScreen> {
);
if (confirmed != true) return;
final e2ee = SimpleE2EEService();
await e2ee.resetIdentityKeys();
await e2ee.forceResetBrokenKeys();
await _loadStatus();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(

View file

@ -10,7 +10,6 @@ import '../models/user_settings.dart';
import '../models/comment.dart';
import '../models/notification.dart';
import '../models/beacon.dart';
import '../models/group.dart';
import '../config/api_config.dart';
import '../services/auth_service.dart';
import '../models/search_results.dart';
@ -176,16 +175,6 @@ class ApiService {
return _callGoApi(path, method: 'GET', queryParams: queryParams);
}
/// Simple POST request helper
Future<Map<String, dynamic>> post(String path, Map<String, dynamic> body) async {
return _callGoApi(path, method: 'POST', body: body);
}
/// Simple DELETE request helper
Future<Map<String, dynamic>> delete(String path) async {
return _callGoApi(path, method: 'DELETE');
}
Future<void> resendVerificationEmail(String email) async {
await _callGoApi('/auth/resend-verification',
@ -570,7 +559,6 @@ class ApiService {
bool isNsfw = false,
String? nsfwReason,
String? visibility,
String? overlayJson,
}) async {
// Validate and sanitize inputs
if (body.isEmpty) {
@ -626,7 +614,6 @@ class ApiService {
if (isNsfw) 'is_nsfw': true,
if (nsfwReason != null) 'nsfw_reason': nsfwReason,
if (visibility != null) 'visibility': visibility,
if (overlayJson != null) 'overlay_json': overlayJson,
},
requireSignature: true,
);
@ -934,14 +921,20 @@ class ApiService {
return (data['groups'] as List?)?.cast<Map<String, dynamic>>() ?? [];
}
Future<List<Map<String, dynamic>>> discoverGroups({String? category, int limit = 50}) async {
final params = <String, String>{'limit': '$limit'};
if (category != null && category != 'all') params['category'] = category;
final data = await _callGoApi('/capsules/discover', method: 'GET', queryParams: params);
return (data['groups'] as List?)?.cast<Map<String, dynamic>>() ?? [];
Future<Map<String, dynamic>> createGroup({
required String name,
String description = '',
String privacy = 'public',
String category = 'general',
}) async {
return await _callGoApi('/capsules/group', body: {
'name': name,
'description': description,
'privacy': privacy,
'category': category,
});
}
Future<Map<String, dynamic>> createCapsule({
required String name,
String description = '',
@ -1028,6 +1021,9 @@ class ApiService {
await _callGoApi('/capsules/$groupId/members/$memberId', method: 'PATCH', body: {'role': role});
}
Future<void> leaveGroup(String groupId) async {
await _callGoApi('/capsules/$groupId/leave', method: 'POST');
}
Future<void> updateGroup(String groupId, {String? name, String? description, String? settings}) async {
await _callGoApi('/capsules/$groupId', method: 'PATCH', body: {
@ -1055,6 +1051,22 @@ class ApiService {
// Social Actions
// =========================================================================
Future<String?> followUser(String userId) async {
final data = await _callGoApi(
'/users/$userId/follow',
method: 'POST',
);
// Prefer explicit status, fallback to message if legacy
return (data['status'] as String?) ?? (data['message'] as String?);
}
Future<void> unfollowUser(String userId) async {
await _callGoApi(
'/users/$userId/follow',
method: 'DELETE',
);
}
Future<List<FollowRequest>> getFollowRequests() async {
final data = await _callGoApi('/users/requests');
final requests = data['requests'] as List<dynamic>? ?? [];
@ -1089,10 +1101,6 @@ class ApiService {
);
}
Future<void> hidePost(String postId) async {
await _callGoApi('/posts/$postId/hide', method: 'POST');
}
Future<void> appreciatePost(String postId) async {
await _callGoApi(
'/posts/$postId/like',
@ -1242,13 +1250,15 @@ class ApiService {
// =========================================================================
Future<String> getSignedMediaUrl(String path) async {
if (path.startsWith('http')) return path;
try {
final data = await callGoApi('/media/sign', method: 'GET', queryParams: {'path': path});
return data['url'] as String? ?? path;
} catch (_) {
// For web platform, return the original URL since signing isn't needed
// for public CDN domains
if (path.startsWith('http')) {
return path;
}
// Migrate to Go API / Nginx Signed URLs
// TODO: Implement proper signed URL generation
return '${ApiConfig.baseUrl}/media/signed?path=$path';
}
Future<Map<String, dynamic>> toggleReaction(String postId, String emoji) async {
@ -1339,30 +1349,14 @@ class ApiService {
// Notifications & Feed (Missing Methods)
// =========================================================================
Future<List<Post>> getPersonalFeed({
int limit = 20,
int offset = 0,
String? filterType,
}) async {
final queryParams = {
'limit': '$limit',
'offset': '$offset',
};
if (filterType != null) {
queryParams['type'] = filterType;
}
final data = await _callGoApi(
'/feed/personal',
Future<List<Post>> getPersonalFeed({int limit = 20, int offset = 0}) async {
final data = await callGoApi(
'/feed',
method: 'GET',
queryParams: queryParams,
queryParams: {'limit': '$limit', 'offset': '$offset'},
);
if (data['posts'] != null) {
return (data['posts'] as List)
.map((json) => Post.fromJson(json))
.toList();
}
return [];
final posts = data['posts'] as List? ?? [];
return posts.map((p) => Post.fromJson(p)).toList();
}
Future<List<Post>> getSojornFeed({int limit = 20, int offset = 0}) async {
@ -1542,147 +1536,4 @@ class ApiService {
},
);
}
// Follow System
// =========================================================================
/// Follow a user
Future<void> followUser(String targetUserId) async {
await _callGoApi('/users/$targetUserId/follow', method: 'POST');
}
/// Unfollow a user
Future<void> unfollowUser(String targetUserId) async {
await _callGoApi('/users/$targetUserId/unfollow', method: 'POST');
}
/// Check if current user follows target user
Future<bool> isFollowing(String targetUserId) async {
final data = await _callGoApi('/users/$targetUserId/is-following', method: 'GET');
return data['is_following'] as bool? ?? false;
}
/// Get mutual followers between current user and target user
Future<List<Map<String, dynamic>>> getMutualFollowers(String targetUserId) async {
final data = await _callGoApi('/users/$targetUserId/mutual-followers', method: 'GET');
return (data['mutual_followers'] as List?)?.cast<Map<String, dynamic>>() ?? [];
}
/// Get suggested users to follow
Future<List<Map<String, dynamic>>> getSuggestedUsers({int limit = 10}) async {
final data = await _callGoApi('/users/suggested', method: 'GET', queryParams: {'limit': '$limit'});
return (data['suggestions'] as List?)?.cast<Map<String, dynamic>>() ?? [];
}
/// Get list of followers for a user
Future<List<Map<String, dynamic>>> getFollowers(String userId) async {
final data = await _callGoApi('/users/$userId/followers', method: 'GET');
return (data['followers'] as List?)?.cast<Map<String, dynamic>>() ?? [];
}
/// Get list of users that a user follows
Future<List<Map<String, dynamic>>> getFollowing(String userId) async {
final data = await _callGoApi('/users/$userId/following', method: 'GET');
return (data['following'] as List?)?.cast<Map<String, dynamic>>() ?? [];
}
// Groups System
// =========================================================================
/// List all groups with optional category filter
Future<List<Group>> listGroups({String? category, int page = 0, int limit = 20}) async {
final queryParams = <String, String>{
'page': page.toString(),
'limit': limit.toString(),
};
if (category != null) {
queryParams['category'] = category;
}
final data = await _callGoApi('/groups', method: 'GET', queryParams: queryParams);
final groups = (data['groups'] as List?) ?? [];
return groups.map((g) => Group.fromJson(g)).toList();
}
/// Get groups the user is a member of
Future<List<Group>> getMyGroups() async {
final data = await _callGoApi('/groups/mine', method: 'GET');
final groups = (data['groups'] as List?) ?? [];
return groups.map((g) => Group.fromJson(g)).toList();
}
/// Get suggested groups for the user
Future<List<SuggestedGroup>> getSuggestedGroups({int limit = 10}) async {
final data = await _callGoApi('/groups/suggested', method: 'GET',
queryParams: {'limit': limit.toString()});
final suggestions = (data['suggestions'] as List?) ?? [];
return suggestions.map((s) => SuggestedGroup.fromJson(s)).toList();
}
/// Get group details by ID
Future<Group> getGroup(String groupId) async {
final data = await _callGoApi('/groups/$groupId', method: 'GET');
return Group.fromJson(data['group']);
}
/// Create a new group
Future<Map<String, dynamic>> createGroup({
required String name,
String? description,
required GroupCategory category,
bool isPrivate = false,
String? avatarUrl,
String? bannerUrl,
}) async {
final body = {
'name': name,
'description': description ?? '',
'category': category.value,
'is_private': isPrivate,
if (avatarUrl != null) 'avatar_url': avatarUrl,
if (bannerUrl != null) 'banner_url': bannerUrl,
};
return await _callGoApi('/groups', method: 'POST', body: body);
}
/// Join a group or request to join (for private groups)
Future<Map<String, dynamic>> joinGroup(String groupId, {String? message}) async {
final body = <String, dynamic>{};
if (message != null) {
body['message'] = message;
}
return await _callGoApi('/groups/$groupId/join', method: 'POST', body: body);
}
/// Leave a group
Future<void> leaveGroup(String groupId) async {
await _callGoApi('/groups/$groupId/leave', method: 'POST');
}
/// Get group members
Future<List<GroupMember>> getGroupMembers(String groupId, {int page = 0, int limit = 50}) async {
final data = await _callGoApi('/groups/$groupId/members', method: 'GET',
queryParams: {'page': page.toString(), 'limit': limit.toString()});
final members = (data['members'] as List?) ?? [];
return members.map((m) => GroupMember.fromJson(m)).toList();
}
/// Get pending join requests (admin only)
Future<List<JoinRequest>> getPendingRequests(String groupId) async {
final data = await _callGoApi('/groups/$groupId/requests', method: 'GET');
final requests = (data['requests'] as List?) ?? [];
return requests.map((r) => JoinRequest.fromJson(r)).toList();
}
/// Approve a join request (admin only)
Future<void> approveJoinRequest(String groupId, String requestId) async {
await _callGoApi('/groups/$groupId/requests/$requestId/approve', method: 'POST');
}
/// Reject a join request (admin only)
Future<void> rejectJoinRequest(String groupId, String requestId) async {
await _callGoApi('/groups/$groupId/requests/$requestId/reject', method: 'POST');
}
}

View file

@ -1,506 +0,0 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:file_picker/file_picker.dart';
import 'package:path_provider/path_provider.dart';
import 'package:permission_handler/permission_handler.dart';
import 'media/ffmpeg.dart';
class AudioOverlayService {
/// Mixes audio with video using FFmpeg
static Future<File?> mixAudioWithVideo(
File videoFile,
File? audioFile,
double volume, // 0.0 to 1.0
bool fadeIn,
bool fadeOut,
) async {
if (audioFile == null) return videoFile;
try {
final tempDir = await getTemporaryDirectory();
final outputFile = File('${tempDir.path}/audio_mix_${DateTime.now().millisecondsSinceEpoch}.mp4');
// Build audio filter
List<String> audioFilters = [];
// Volume adjustment
if (volume != 1.0) {
audioFilters.add('volume=${volume}');
}
// Fade in
if (fadeIn) {
audioFilters.add('afade=t=in:st=0:d=1');
}
// Fade out
if (fadeOut) {
audioFilters.add('afade=t=out:st=3:d=1');
}
String audioFilterString = '';
if (audioFilters.isNotEmpty) {
audioFilterString = '-af "${audioFilters.join(',')}"';
}
// FFmpeg command to mix audio
final command = "-i '${videoFile.path}' -i '${audioFile.path}' $audioFilterString -c:v copy -c:a aac -shortest '${outputFile.path}'";
final session = await FFmpegKit.execute(command);
final returnCode = await session.getReturnCode();
if (ReturnCode.isSuccess(returnCode)) {
return outputFile;
} else {
final logs = await session.getOutput();
print('Audio mixing error: $logs');
return null;
}
} catch (e) {
print('Audio mixing error: $e');
return null;
}
}
/// Pick audio file from device
static Future<File?> pickAudioFile() async {
try {
// Request storage permission if needed
if (!kIsWeb && Platform.isAndroid) {
final status = await Permission.storage.request();
if (status != PermissionStatus.granted) {
return null;
}
}
FilePickerResult? result = await FilePicker.platform.pickFiles(
type: FileType.audio,
allowMultiple: false,
);
if (result != null && result.files.single.path != null) {
return File(result.files.single.path!);
}
return null;
} catch (e) {
print('Audio file picker error: $e');
return null;
}
}
/// Get audio duration
static Future<Duration?> getAudioDuration(File audioFile) async {
try {
final command = "-i '${audioFile.path}' -f null -";
final session = await FFmpegKit.execute(command);
final output = await session.getOutput() ?? '';
final logs = output.split('\n');
for (final message in logs) {
if (message.contains('Duration:')) {
// Parse duration from FFmpeg output
final durationMatch = RegExp(r'Duration: (\d{2}):(\d{2}):(\d{2}\.\d{2})').firstMatch(message);
if (durationMatch != null) {
final hours = int.parse(durationMatch.group(1)!);
final minutes = int.parse(durationMatch.group(2)!);
final seconds = double.parse(durationMatch.group(3)!);
return Duration(
hours: hours,
minutes: minutes,
seconds: seconds.toInt(),
milliseconds: ((seconds - seconds.toInt()) * 1000).toInt(),
);
}
}
}
return null;
} catch (e) {
print('Audio duration error: $e');
return null;
}
}
/// Built-in music library (demo tracks)
static List<MusicTrack> getBuiltInTracks() {
return [
MusicTrack(
id: 'upbeat_pop',
title: 'Upbeat Pop',
artist: 'Sojorn Library',
duration: const Duration(seconds: 30),
genre: 'Pop',
mood: 'Happy',
isBuiltIn: true,
),
MusicTrack(
id: 'chill_lofi',
title: 'Chill Lo-Fi',
artist: 'Sojorn Library',
duration: const Duration(seconds: 45),
genre: 'Lo-Fi',
mood: 'Relaxed',
isBuiltIn: true,
),
MusicTrack(
id: 'energetic_dance',
title: 'Energetic Dance',
artist: 'Sojorn Library',
duration: const Duration(seconds: 30),
genre: 'Dance',
mood: 'Excited',
isBuiltIn: true,
),
MusicTrack(
id: 'acoustic_guitar',
title: 'Acoustic Guitar',
artist: 'Sojorn Library',
duration: const Duration(seconds: 40),
genre: 'Acoustic',
mood: 'Calm',
isBuiltIn: true,
),
MusicTrack(
id: 'electronic_beats',
title: 'Electronic Beats',
artist: 'Sojorn Library',
duration: const Duration(seconds: 35),
genre: 'Electronic',
mood: 'Modern',
isBuiltIn: true,
),
MusicTrack(
id: 'cinematic_ambient',
title: 'Cinematic Ambient',
artist: 'Sojorn Library',
duration: const Duration(seconds: 50),
genre: 'Ambient',
mood: 'Dramatic',
isBuiltIn: true,
),
];
}
}
class MusicTrack {
final String id;
final String title;
final String artist;
final Duration duration;
final String genre;
final String mood;
final bool isBuiltIn;
final File? audioFile;
MusicTrack({
required this.id,
required this.title,
required this.artist,
required this.duration,
required this.genre,
required this.mood,
required this.isBuiltIn,
this.audioFile,
});
MusicTrack copyWith({
String? id,
String? title,
String? artist,
Duration? duration,
String? genre,
String? mood,
bool? isBuiltIn,
File? audioFile,
}) {
return MusicTrack(
id: id ?? this.id,
title: title ?? this.title,
artist: artist ?? this.artist,
duration: duration ?? this.duration,
genre: genre ?? this.genre,
mood: mood ?? this.mood,
isBuiltIn: isBuiltIn ?? this.isBuiltIn,
audioFile: audioFile ?? this.audioFile,
);
}
}
class AudioOverlayControls extends StatefulWidget {
final Function(MusicTrack?) onTrackSelected;
final Function(double) onVolumeChanged;
final Function(bool) onFadeInChanged;
final Function(bool) onFadeOutChanged;
const AudioOverlayControls({
super.key,
required this.onTrackSelected,
required this.onVolumeChanged,
required this.onFadeInChanged,
required this.onFadeOutChanged,
});
@override
State<AudioOverlayControls> createState() => _AudioOverlayControlsState();
}
class _AudioOverlayControlsState extends State<AudioOverlayControls> {
MusicTrack? _selectedTrack;
double _volume = 0.5;
bool _fadeIn = true;
bool _fadeOut = true;
List<MusicTrack> _availableTracks = [];
@override
void initState() {
super.initState();
_loadTracks();
}
Future<void> _loadTracks() async {
final builtInTracks = AudioOverlayService.getBuiltInTracks();
setState(() {
_availableTracks = builtInTracks;
});
}
Future<void> _pickCustomAudio() async {
final audioFile = await AudioOverlayService.pickAudioFile();
if (audioFile != null) {
final duration = await AudioOverlayService.getAudioDuration(audioFile);
final customTrack = MusicTrack(
id: 'custom_${DateTime.now().millisecondsSinceEpoch}',
title: 'Custom Audio',
artist: 'User Upload',
duration: duration ?? const Duration(seconds: 30),
genre: 'Custom',
mood: 'User',
isBuiltIn: false,
audioFile: audioFile,
);
setState(() {
_availableTracks.insert(0, customTrack);
_selectedTrack = customTrack;
});
widget.onTrackSelected(customTrack);
}
}
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.black87,
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Audio Overlay',
style: TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
TextButton.icon(
onPressed: _pickCustomAudio,
icon: const Icon(Icons.upload_file, color: Colors.white, size: 16),
label: const Text('Upload', style: TextStyle(color: Colors.white)),
),
],
),
const SizedBox(height: 16),
// Track selection
if (_availableTracks.isNotEmpty)
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Select Track',
style: TextStyle(color: Colors.white70, fontSize: 14),
),
const SizedBox(height: 8),
SizedBox(
height: 120,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: _availableTracks.length,
itemBuilder: (context, index) {
final track = _availableTracks[index];
final isSelected = _selectedTrack?.id == track.id;
return GestureDetector(
onTap: () {
setState(() {
_selectedTrack = track;
});
widget.onTrackSelected(track);
},
child: Container(
width: 100,
margin: const EdgeInsets.only(right: 8),
decoration: BoxDecoration(
color: isSelected ? Colors.blue : Colors.grey[800],
borderRadius: BorderRadius.circular(8),
border: isSelected ? Border.all(color: Colors.blue) : null,
),
padding: const EdgeInsets.all(8),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
track.isBuiltIn ? Icons.music_note : Icons.audiotrack,
color: Colors.white,
size: 24,
),
const SizedBox(height: 4),
Text(
track.title,
style: const TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Text(
_formatDuration(track.duration),
style: const TextStyle(
color: Colors.white70,
fontSize: 9,
),
),
],
),
),
);
},
),
),
],
),
const SizedBox(height: 16),
// Volume control
Row(
children: [
const Icon(Icons.volume_down, color: Colors.white, size: 20),
Expanded(
child: Slider(
value: _volume,
min: 0.0,
max: 1.0,
divisions: 10,
label: '${(_volume * 100).toInt()}%',
activeColor: Colors.blue,
inactiveColor: Colors.grey[600],
onChanged: (value) {
setState(() {
_volume = value;
});
widget.onVolumeChanged(value);
},
),
),
const Icon(Icons.volume_up, color: Colors.white, size: 20),
],
),
const SizedBox(height: 12),
// Fade controls
Row(
children: [
Expanded(
child: GestureDetector(
onTap: () {
setState(() {
_fadeIn = !_fadeIn;
});
widget.onFadeInChanged(_fadeIn);
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: _fadeIn ? Colors.blue : Colors.grey[700],
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.volume_up,
color: Colors.white,
size: 16,
),
const SizedBox(width: 4),
const Text(
'Fade In',
style: TextStyle(color: Colors.white, fontSize: 12),
),
],
),
),
),
),
const SizedBox(width: 8),
Expanded(
child: GestureDetector(
onTap: () {
setState(() {
_fadeOut = !_fadeOut;
});
widget.onFadeOutChanged(_fadeOut);
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: _fadeOut ? Colors.blue : Colors.grey[700],
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.volume_down,
color: Colors.white,
size: 16,
),
const SizedBox(width: 4),
const Text(
'Fade Out',
style: TextStyle(color: Colors.white, fontSize: 12),
),
],
),
),
),
),
],
),
],
),
);
}
String _formatDuration(Duration duration) {
final minutes = duration.inMinutes;
final seconds = duration.inSeconds % 60;
return '${minutes}:${seconds.toString().padLeft(2, '0')}';
}
}

View file

@ -1,658 +0,0 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:file_picker/file_picker.dart';
import 'package:path_provider/path_provider.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:share_plus/share_plus.dart';
class BlockingService {
static const String _blockedUsersKey = 'blocked_users';
static const String _blockedUsersJsonKey = 'blocked_users_json';
static const String _blockedUsersCsvKey = 'blocked_users_csv';
/// Export blocked users to JSON file
static Future<bool> exportBlockedUsersToJson(List<String> blockedUserIds) async {
try {
final directory = await getApplicationDocumentsDirectory();
final file = File('${directory.path}/blocked_users_${DateTime.now().millisecondsSinceEpoch}.json');
final exportData = {
'exported_at': DateTime.now().toIso8601String(),
'version': '2.0',
'platform': 'sojorn',
'total_blocked': blockedUserIds.length,
'blocked_users': blockedUserIds.map((id) => {
'user_id': id,
'blocked_at': DateTime.now().toIso8601String(),
}).toList(),
};
await file.writeAsString(const JsonEncoder.withIndent(' ').convert(exportData));
// Share the file
final result = await Share.shareXFiles([XFile(file.path)]);
return result.status == ShareResultStatus.success;
} catch (e) {
print('Error exporting blocked users to JSON: $e');
return false;
}
}
/// Export blocked users to CSV file
static Future<bool> exportBlockedUsersToCsv(List<String> blockedUserIds) async {
try {
final directory = await getApplicationDocumentsDirectory();
final file = File('${directory.path}/blocked_users_${DateTime.now().millisecondsSinceEpoch}.csv');
final csvContent = StringBuffer();
csvContent.writeln('user_id,blocked_at');
for (final userId in blockedUserIds) {
csvContent.writeln('$userId,${DateTime.now().toIso8601String()}');
}
await file.writeAsString(csvContent.toString());
// Share the file
final result = await Share.shareXFiles([XFile(file.path)]);
return result.status == ShareResultStatus.success;
} catch (e) {
print('Error exporting blocked users to CSV: $e');
return false;
}
}
/// Import blocked users from JSON file
static Future<List<String>> importBlockedUsersFromJson() async {
try {
final result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: ['json'],
allowMultiple: false,
);
if (result != null && result.files.single.path != null) {
final file = File(result.files.single.path!);
final content = await file.readAsString();
final data = jsonDecode(content) as Map<String, dynamic>;
if (data['blocked_users'] != null) {
final blockedUsers = (data['blocked_users'] as List<dynamic>)
.map((user) => user['user_id'] as String)
.toList();
return blockedUsers;
}
}
} catch (e) {
print('Error importing blocked users from JSON: $e');
}
return [];
}
/// Import blocked users from CSV file
static Future<List<String>> importBlockedUsersFromCsv() async {
try {
final result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: ['csv'],
allowMultiple: false,
);
if (result != null && result.files.single.path != null) {
final file = File(result.files.single.path!);
final lines = await file.readAsLines();
if (lines.isNotEmpty) {
// Skip header line
final blockedUsers = lines.skip(1)
.where((line) => line.isNotEmpty)
.map((line) => line.split(',')[0].trim())
.toList();
return blockedUsers;
}
}
} catch (e) {
print('Error importing blocked users from CSV: $e');
}
return [];
}
/// Import from Twitter/X format
static Future<List<String>> importFromTwitterX() async {
try {
final result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: ['csv'],
allowMultiple: false,
);
if (result != null && result.files.single.path != null) {
final file = File(result.files.single.path!);
final lines = await file.readAsLines();
if (lines.isNotEmpty) {
// Twitter/X CSV format: screen_name, name, description, following, followers, tweets, account_created_at
final blockedUsers = lines.skip(1)
.where((line) => line.isNotEmpty)
.map((line) => line.split(',')[0].trim()) // screen_name
.toList();
return blockedUsers;
}
}
} catch (e) {
print('Error importing from Twitter/X: $e');
}
return [];
}
/// Import from Mastodon format
static Future<List<String>> importFromMastodon() async {
try {
final result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: ['csv'],
allowMultiple: false,
);
if (result != null && result.files.single.path != null) {
final file = File(result.files.single.path!);
final lines = await file.readAsLines();
if (lines.isNotEmpty) {
// Mastodon CSV format: account_id, username, display_name, domain, note, created_at
final blockedUsers = lines.skip(1)
.where((line) => line.isNotEmpty)
.map((line) => line.split(',')[1].trim()) // username
.toList();
return blockedUsers;
}
}
} catch (e) {
print('Error importing from Mastodon: $e');
}
return [];
}
/// Get supported platform formats
static List<PlatformFormat> getSupportedFormats() {
return [
PlatformFormat(
name: 'Sojorn JSON',
description: 'Native Sojorn format with full metadata',
extension: 'json',
importFunction: importBlockedUsersFromJson,
exportFunction: exportBlockedUsersToJson,
),
PlatformFormat(
name: 'CSV',
description: 'Universal CSV format',
extension: 'csv',
importFunction: importBlockedUsersFromCsv,
exportFunction: exportBlockedUsersToCsv,
),
PlatformFormat(
name: 'Twitter/X',
description: 'Twitter/X export format',
extension: 'csv',
importFunction: importFromTwitterX,
exportFunction: null, // Export not supported for Twitter/X
),
PlatformFormat(
name: 'Mastodon',
description: 'Mastodon export format',
extension: 'csv',
importFunction: importFromMastodon,
exportFunction: null, // Export not supported for Mastodon
),
];
}
/// Validate blocked users list
static Future<List<String>> validateBlockedUsers(List<String> blockedUserIds) async {
final validUsers = <String>[];
for (final userId in blockedUserIds) {
if (userId.isNotEmpty && userId.length <= 50) { // Basic validation
validUsers.add(userId);
}
}
return validUsers;
}
/// Get import/export statistics
static Map<String, dynamic> getStatistics(List<String> blockedUserIds) {
return {
'total_blocked': blockedUserIds.length,
'export_formats_available': getSupportedFormats().length,
'last_updated': DateTime.now().toIso8601String(),
'platforms_supported': ['Twitter/X', 'Mendation', 'CSV', 'JSON'],
};
}
}
class PlatformFormat {
final String name;
final String description;
final String extension;
final Future<List<String>> Function()? importFunction;
final Future<bool>? Function(List<String>)? exportFunction;
PlatformFormat({
required this.name,
required this.description,
required this.extension,
this.importFunction,
this.exportFunction,
});
}
class BlockManagementScreen extends StatefulWidget {
const BlockManagementScreen({super.key});
@override
State<BlockManagementScreen> createState() => _BlockManagementScreenState();
}
class _BlockManagementScreenState extends State<BlockManagementScreen> {
List<String> _blockedUsers = [];
bool _isLoading = false;
String? _errorMessage;
@override
void initState() {
super.initState();
_loadBlockedUsers();
}
Future<void> _loadBlockedUsers() async {
setState(() {
_isLoading = true;
_errorMessage = null;
});
try {
// This would typically come from your API service
// For now, we'll use a placeholder
final prefs = await SharedPreferences.getInstance();
final blockedUsersJson = prefs.getString(BlockingService._blockedUsersJsonKey);
if (blockedUsersJson != null) {
final blockedUsersList = jsonDecode(blockedUsersJson) as List<dynamic>;
_blockedUsers = blockedUsersList.cast<String>();
}
} catch (e) {
setState(() {
_errorMessage = 'Failed to load blocked users';
});
} finally {
setState(() {
_isLoading = false;
});
}
}
Future<void> _saveBlockedUsers() async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(BlockingService._blockedUsersJsonKey, jsonEncode(_blockedUsers));
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Failed to save blocked users')),
);
}
}
void _showImportDialog() {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Import Block List'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text('Choose the format of your block list:'),
const SizedBox(height: 16),
...BlockingService.getSupportedFormats().map((format) => ListTile(
leading: Icon(
format.importFunction != null ? Icons.file_download : Icons.file_upload,
color: format.importFunction != null ? Colors.green : Colors.grey,
),
title: Text(format.name),
subtitle: Text(format.description),
trailing: format.importFunction != null
? const Icon(Icons.arrow_forward_ios, color: Colors.grey)
: null,
onTap: format.importFunction != null
? () => _importFromFormat(format)
: null,
)).toList(),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
],
),
);
}
Future<void> _importFromFormat(PlatformFormat format) async {
Navigator.pop(context);
setState(() => _isLoading = true);
try {
final importedUsers = await format.importFunction!();
final validatedUsers = await BlockingService.validateBlockedUsers(importedUsers);
setState(() {
_blockedUsers = {..._blockedUsers, ...validatedUsers}.toList();
});
await _saveBlockedUsers();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Successfully imported ${validatedUsers.length} users'),
backgroundColor: Colors.green,
),
);
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to import: $e'),
backgroundColor: Colors.red,
),
);
} finally {
setState(() => _isLoading = false);
}
}
void _showExportDialog() {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Export Block List'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text('Choose export format:'),
const SizedBox(height: 16),
...BlockingService.getSupportedFormats().where((format) => format.exportFunction != null).map((format) => ListTile(
leading: Icon(Icons.file_upload, color: Colors.blue),
title: Text(format.name),
subtitle: Text(format.description),
onTap: () => _exportToFormat(format),
)).toList(),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
],
),
);
}
Future<void> _exportToFormat(PlatformFormat format) async {
Navigator.pop(context);
setState(() => _isLoading = true);
try {
final success = await format.exportFunction!(_blockedUsers);
if (success == true) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Successfully exported ${_blockedUsers.length} users'),
backgroundColor: Colors.green,
),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('Export cancelled or failed'),
backgroundColor: Colors.orange,
),
);
}
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Export failed: $e'),
backgroundColor: Colors.red,
),
);
} finally {
setState(() => _isLoading = false);
}
}
void _showBulkBlockDialog() {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Bulk Block'),
content: const Text('Enter usernames to block (one per line):'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: () {
Navigator.pop(context);
_showBulkBlockInput();
},
child: const Text('Next'),
),
],
),
);
}
void _showBulkBlockInput() {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Bulk Block'),
content: TextField(
decoration: const InputDecoration(
border: OutlineInputBorder(),
hintText: 'user1\nuser2\nuser3',
),
maxLines: 10,
onChanged: (value) {
// This would typically validate usernames
},
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: () {
Navigator.pop(context);
// Process bulk block here
},
child: const Text('Block Users'),
),
],
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
appBar: AppBar(
backgroundColor: Colors.black,
title: const Text(
'Block Management',
style: TextStyle(color: Colors.white),
),
iconTheme: const IconThemeData(color: Colors.white),
actions: [
IconButton(
onPressed: _showImportDialog,
icon: const Icon(Icons.file_download, color: Colors.white),
tooltip: 'Import',
),
IconButton(
onPressed: _showExportDialog,
icon: const Icon(Icons.file_upload, color: Colors.white),
tooltip: 'Export',
),
IconButton(
onPressed: _showBulkBlockDialog,
icon: const Icon(Icons.group_add, color: Colors.white),
tooltip: 'Bulk Block',
),
],
),
body: _isLoading
? const Center(
child: CircularProgressIndicator(color: Colors.white),
)
: _errorMessage != null
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.error_outline,
color: Colors.red,
size: 48,
),
const SizedBox(height: 16),
Text(
_errorMessage!,
style: const TextStyle(color: Colors.white),
textAlign: TextAlign.center,
),
],
),
)
: _blockedUsers.isEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.block,
color: Colors.grey,
size: 48,
),
const SizedBox(height: 16),
const Text(
'No blocked users',
style: TextStyle(
color: Colors.grey,
fontSize: 18,
),
),
const SizedBox(height: 8),
const Text(
'Import an existing block list or start blocking users',
style: TextStyle(
color: Colors.grey,
fontSize: 14,
),
textAlign: TextAlign.center,
),
],
),
)
: Column(
children: [
// Statistics
Container(
margin: const EdgeInsets.all(16),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.grey[900],
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Statistics',
style: TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
'Total Blocked: ${_blockedUsers.length}',
style: const TextStyle(
color: Colors.white,
fontSize: 14,
),
),
const SizedBox(height: 4),
Text(
'Last Updated: ${DateTime.now().toIso8601String()}',
style: TextStyle(
color: Colors.grey[400],
fontSize: 12,
),
),
],
),
),
// Blocked users list
Expanded(
child: ListView.builder(
itemCount: _blockedUsers.length,
itemBuilder: (context, index) {
final userId = _blockedUsers[index];
return ListTile(
leading: CircleAvatar(
backgroundColor: Colors.grey[700],
child: const Icon(
Icons.person,
color: Colors.white,
),
),
title: Text(
userId,
style: const TextStyle(color: Colors.white),
),
trailing: IconButton(
icon: const Icon(Icons.close, color: Colors.red),
onPressed: () {
setState(() {
_blockedUsers.removeAt(index);
});
_saveBlockedUsers();
},
),
);
},
),
),
],
),
);
}
}

View file

@ -1,6 +0,0 @@
// E2EE device sync service stub (encrypt/pointycastle/qr_flutter not in pubspec)
// Full implementation deferred until those packages are added.
class E2EEDeviceSyncService {
// TODO: implement when encrypt, pointycastle, qr_flutter are added to pubspec
}

View file

@ -84,9 +84,6 @@ class ImageUploadService {
throw UploadException('Not authenticated. Please sign in again.');
}
// Strip metadata (GPS, device info, timestamps) before upload
final sanitized = await MediaSanitizer.sanitizeVideo(videoFile);
// Use Go API upload endpoint with R2 integration
final uri = Uri.parse('${ApiConfig.baseUrl}/upload');
@ -95,14 +92,15 @@ class ImageUploadService {
request.headers['Authorization'] = 'Bearer $token';
// CRITICAL: Use fromPath to stream from disk instead of loading into memory
final fileLength = await videoFile.length();
request.files.add(await http.MultipartFile.fromPath(
'media', // Field name matches upload-media
sanitized.path,
videoFile.path,
contentType: http_parser.MediaType.parse('video/mp4'),
));
request.fields['type'] = 'video';
request.fields['fileName'] = sanitized.path.split('/').last;
request.fields['fileName'] = videoFile.path.split('/').last;
onProgress?.call(0.1);

View file

@ -2,7 +2,6 @@ import 'dart:io';
import 'package:flutter/services.dart';
import 'package:flutter_image_compress/flutter_image_compress.dart';
import 'package:image/image.dart' as img;
import 'media/ffmpeg.dart';
class MediaSanitizer {
static Future<File> sanitizeImage(File rawFile) async {
@ -40,6 +39,10 @@ class MediaSanitizer {
}
static Future<File> sanitizeVideo(File rawFile) async {
// For videos, we just validate and return the original file
// Video processing is handled by the video compression library
// This method ensures the file exists and is readable
if (!await rawFile.exists()) {
throw Exception('Video file does not exist');
}
@ -51,6 +54,7 @@ class MediaSanitizer {
throw Exception('Video size exceeds 50MB limit');
}
// Check if it's a valid video file by extension
final fileName = rawFile.path.split('/').last.toLowerCase();
final extension = fileName.split('.').last;
const validExtensions = {'mp4', 'mov', 'webm'};
@ -59,23 +63,7 @@ class MediaSanitizer {
throw Exception('Unsupported video format: $extension');
}
// Strip all metadata (GPS, device info, timestamps) via FFmpeg remux no re-encode.
try {
final tempDir = Directory.systemTemp;
final output = File(
'${tempDir.path}${Platform.pathSeparator}stripped_${DateTime.now().microsecondsSinceEpoch}.mp4',
);
final session = await FFmpegKit.execute(
'-y -i "${rawFile.path}" -map_metadata -1 -c copy "${output.path}"',
);
final rc = await session.getReturnCode();
if (ReturnCode.isSuccess(rc) && await output.exists()) {
return output;
}
} catch (_) {
// FFmpeg unavailable fall through and return original
}
// Return the original file as videos don't need sanitization like images
return rawFile;
}
}

View file

@ -1,52 +0,0 @@
import 'dart:async';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:connectivity_plus/connectivity_plus.dart';
/// Service for monitoring network connectivity status
class NetworkService {
static final NetworkService _instance = NetworkService._internal();
factory NetworkService() => _instance;
NetworkService._internal();
Connectivity? _connectivity;
final StreamController<bool> _connectionController =
StreamController<bool>.broadcast();
Stream<bool> get connectionStream => _connectionController.stream;
bool _isConnected = true;
bool get isConnected => _isConnected;
/// Initialize the network service and start monitoring
void initialize() {
// Skip connectivity monitoring on web - it's not supported
if (kIsWeb) {
_isConnected = true;
_connectionController.add(true);
return;
}
_connectivity = Connectivity();
_connectivity!.onConnectivityChanged.listen((List<ConnectivityResult> results) {
final result = results.isNotEmpty ? results.first : ConnectivityResult.none;
_isConnected = result != ConnectivityResult.none;
_connectionController.add(_isConnected);
});
// Check initial state
_checkConnection();
}
Future<void> _checkConnection() async {
if (kIsWeb || _connectivity == null) return;
final results = await _connectivity!.checkConnectivity();
final result = results.isNotEmpty ? results.first : ConnectivityResult.none;
_isConnected = result != ConnectivityResult.none;
_connectionController.add(_isConnected);
}
void dispose() {
_connectionController.close();
}
}

View file

@ -1,361 +0,0 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:sojorn/models/repost.dart';
import 'package:sojorn/models/post.dart';
import 'package:sojorn/services/api_service.dart';
import 'package:sojorn/providers/api_provider.dart';
class RepostService {
static const String _repostsCacheKey = 'reposts_cache';
static const String _amplificationCacheKey = 'amplification_cache';
static const Duration _cacheExpiry = Duration(minutes: 5);
/// Create a new repost
Future<Repost?> createRepost({
required String originalPostId,
required RepostType type,
String? comment,
Map<String, dynamic>? metadata,
}) async {
try {
final response = await ApiService.instance.post('/posts/repost', {
'original_post_id': originalPostId,
'type': type.name,
'comment': comment,
'metadata': metadata,
});
if (response['success'] == true) {
return Repost.fromJson(response['repost']);
}
} catch (e) {
print('Error creating repost: $e');
}
return null;
}
/// Boost a post (amplify its reach)
Future<bool> boostPost({
required String postId,
required RepostType boostType,
int? boostAmount,
}) async {
try {
final response = await ApiService.instance.post('/posts/boost', {
'post_id': postId,
'boost_type': boostType.name,
'boost_amount': boostAmount ?? 1,
});
return response['success'] == true;
} catch (e) {
print('Error boosting post: $e');
return false;
}
}
/// Get all reposts for a post
Future<List<Repost>> getRepostsForPost(String postId) async {
try {
final response = await ApiService.instance.get('/posts/$postId/reposts');
if (response['success'] == true) {
final repostsData = response['reposts'] as List<dynamic>? ?? [];
return repostsData.map((r) => Repost.fromJson(r as Map<String, dynamic>)).toList();
}
} catch (e) {
print('Error getting reposts: $e');
}
return [];
}
/// Get user's repost history
Future<List<Repost>> getUserReposts(String userId, {int limit = 20}) async {
try {
final response = await ApiService.instance.get('/users/$userId/reposts?limit=$limit');
if (response['success'] == true) {
final repostsData = response['reposts'] as List<dynamic>? ?? [];
return repostsData.map((r) => Repost.fromJson(r as Map<String, dynamic>)).toList();
}
} catch (e) {
print('Error getting user reposts: $e');
}
return [];
}
/// Delete a repost
Future<bool> deleteRepost(String repostId) async {
try {
final response = await ApiService.instance.delete('/reposts/$repostId');
return response['success'] == true;
} catch (e) {
print('Error deleting repost: $e');
return false;
}
}
/// Get amplification analytics for a post
Future<AmplificationAnalytics?> getAmplificationAnalytics(String postId) async {
try {
final response = await ApiService.instance.get('/posts/$postId/amplification');
if (response['success'] == true) {
return AmplificationAnalytics.fromJson(response['analytics']);
}
} catch (e) {
print('Error getting amplification analytics: $e');
}
return null;
}
/// Get trending posts based on amplification
Future<List<Post>> getTrendingPosts({int limit = 10, String? category}) async {
try {
String url = '/posts/trending?limit=$limit';
if (category != null) {
url += '&category=$category';
}
final response = await ApiService.instance.get(url);
if (response['success'] == true) {
final postsData = response['posts'] as List<dynamic>? ?? [];
return postsData.map((p) => Post.fromJson(p as Map<String, dynamic>)).toList();
}
} catch (e) {
print('Error getting trending posts: $e');
}
return [];
}
/// Get amplification rules
Future<List<FeedAmplificationRule>> getAmplificationRules() async {
try {
final response = await ApiService.instance.get('/amplification/rules');
if (response['success'] == true) {
final rulesData = response['rules'] as List<dynamic>? ?? [];
return rulesData.map((r) => FeedAmplificationRule.fromJson(r as Map<String, dynamic>)).toList();
}
} catch (e) {
print('Error getting amplification rules: $e');
}
return [];
}
/// Calculate amplification score for a post
Future<int> calculateAmplificationScore(String postId) async {
try {
final response = await ApiService.instance.post('/posts/$postId/calculate-score', {});
if (response['success'] == true) {
return response['score'] as int? ?? 0;
}
} catch (e) {
print('Error calculating amplification score: $e');
}
return 0;
}
/// Check if user can boost a post
Future<bool> canBoostPost(String userId, String postId, RepostType boostType) async {
try {
final response = await ApiService.instance.get('/users/$userId/can-boost/$postId?type=${boostType.name}');
return response['can_boost'] == true;
} catch (e) {
print('Error checking boost eligibility: $e');
return false;
}
}
/// Get user's daily boost count
Future<Map<RepostType, int>> getDailyBoostCount(String userId) async {
try {
final response = await ApiService.instance.get('/users/$userId/daily-boosts');
if (response['success'] == true) {
final boostCounts = response['boost_counts'] as Map<String, dynamic>? ?? {};
final result = <RepostType, int>{};
boostCounts.forEach((type, count) {
final repostType = RepostType.fromString(type);
result[repostType] = count as int;
});
return result;
}
} catch (e) {
print('Error getting daily boost count: $e');
}
return {};
}
/// Report inappropriate repost
Future<bool> reportRepost(String repostId, String reason) async {
try {
final response = await ApiService.instance.post('/reposts/$repostId/report', {
'reason': reason,
});
return response['success'] == true;
} catch (e) {
print('Error reporting repost: $e');
return false;
}
}
}
// Riverpod providers
final repostServiceProvider = Provider<RepostService>((ref) {
return RepostService();
});
final repostsProvider = FutureProvider.family<List<Repost>, String>((ref, postId) {
final service = ref.watch(repostServiceProvider);
return service.getRepostsForPost(postId);
});
final amplificationAnalyticsProvider = FutureProvider.family<AmplificationAnalytics?, String>((ref, postId) {
final service = ref.watch(repostServiceProvider);
return service.getAmplificationAnalytics(postId);
});
final trendingPostsProvider = FutureProvider.family<List<Post>, Map<String, dynamic>>((ref, params) {
final service = ref.watch(repostServiceProvider);
final limit = params['limit'] as int? ?? 10;
final category = params['category'] as String?;
return service.getTrendingPosts(limit: limit, category: category);
});
class RepostController extends Notifier<RepostState> {
@override
RepostState build() => const RepostState();
RepostService get _service => ref.read(repostServiceProvider);
Future<void> createRepost({
required String originalPostId,
required RepostType type,
String? comment,
Map<String, dynamic>? metadata,
}) async {
state = state.copyWith(isLoading: true, error: null);
try {
final repost = await _service.createRepost(
originalPostId: originalPostId,
type: type,
comment: comment,
metadata: metadata,
);
if (repost != null) {
state = state.copyWith(
isLoading: false,
lastRepost: repost,
error: null,
);
} else {
state = state.copyWith(
isLoading: false,
error: 'Failed to create repost',
);
}
} catch (e) {
state = state.copyWith(
isLoading: false,
error: 'Error creating repost: $e',
);
}
}
Future<void> boostPost({
required String postId,
required RepostType boostType,
int? boostAmount,
}) async {
state = state.copyWith(isLoading: true, error: null);
try {
final success = await _service.boostPost(
postId: postId,
boostType: boostType,
boostAmount: boostAmount,
);
state = state.copyWith(
isLoading: false,
lastBoostSuccess: success,
error: success ? null : 'Failed to boost post',
);
} catch (e) {
state = state.copyWith(
isLoading: false,
error: 'Error boosting post: $e',
);
}
}
Future<void> deleteRepost(String repostId) async {
state = state.copyWith(isLoading: true, error: null);
try {
final success = await _service.deleteRepost(repostId);
state = state.copyWith(
isLoading: false,
lastDeleteSuccess: success,
error: success ? null : 'Failed to delete repost',
);
} catch (e) {
state = state.copyWith(
isLoading: false,
error: 'Error deleting repost: $e',
);
}
}
void clearError() {
state = state.copyWith(error: null);
}
void reset() {
state = const RepostState();
}
}
class RepostState {
final bool isLoading;
final String? error;
final Repost? lastRepost;
final bool? lastBoostSuccess;
final bool? lastDeleteSuccess;
const RepostState({
this.isLoading = false,
this.error,
this.lastRepost,
this.lastBoostSuccess,
this.lastDeleteSuccess,
});
RepostState copyWith({
bool? isLoading,
String? error,
Repost? lastRepost,
bool? lastBoostSuccess,
bool? lastDeleteSuccess,
}) {
return RepostState(
isLoading: isLoading ?? this.isLoading,
error: error ?? this.error,
lastRepost: lastRepost ?? this.lastRepost,
lastBoostSuccess: lastBoostSuccess ?? this.lastBoostSuccess,
lastDeleteSuccess: lastDeleteSuccess ?? this.lastDeleteSuccess,
);
}
}
final repostControllerProvider = NotifierProvider<RepostController, RepostState>(RepostController.new);

View file

@ -81,8 +81,9 @@ class SecureChatService {
}
}
Future<void> resetIdentityKeys() async {
await _e2ee.resetIdentityKeys();
// Force reset to fix 208-bit key bug
Future<void> forceResetBrokenKeys() async {
await _e2ee.forceResetBrokenKeys();
}
// Manual key upload for testing

View file

@ -96,18 +96,36 @@ class SimpleE2EEService {
}
// Reset all local encryption keys and generate a fresh identity.
// Existing encrypted messages will become undecryptable after this.
Future<void> resetIdentityKeys() async {
// Force reset to fix 208-bit key bug
Future<void> forceResetBrokenKeys() async {
// Clear ALL storage completely
await _storage.deleteAll();
// Clear local key variables
_identityDhKeyPair = null;
_identitySigningKeyPair = null;
_signedPreKey = null;
_oneTimePreKeys = null;
_initializedForUserId = null;
_initFuture = null;
// Clear session cache
_sessionCache.clear();
// Generate fresh identity with proper key lengths
await generateNewIdentity();
// Verify the new keys are proper length
if (_identityDhKeyPair != null) {
final publicKey = await _identityDhKeyPair!.extractPublicKey();
}
if (_identitySigningKeyPair != null) {
final publicKey = await _identitySigningKeyPair!.extractPublicKey();
}
}
// Manual key upload for testing

View file

@ -3,142 +3,50 @@ import 'media/ffmpeg.dart';
import 'package:path_provider/path_provider.dart';
class VideoStitchingService {
/// Enhanced video stitching with filters, speed control, and text overlays
/// Stitches multiple video files into a single video file using FFmpeg.
///
/// Returns the processed video file, or null if processing failed.
static Future<File?> stitchVideos(
List<File> segments,
List<Duration> segmentDurations,
String filter,
double playbackSpeed,
Map<String, dynamic>? textOverlay, {
String? audioOverlayPath,
double audioVolume = 0.5,
}) async {
/// Returns the stitched file, or null if stitching failed or input is empty.
static Future<File?> stitchVideos(List<File> segments) async {
if (segments.isEmpty) return null;
if (segments.length == 1 && filter == 'none' && playbackSpeed == 1.0 && textOverlay == null) {
return segments.first;
}
if (segments.length == 1) return segments.first;
try {
// 1. Create a temporary file listing all segments for FFmpeg concat demuxer
final tempDir = await getTemporaryDirectory();
final outputFile = File('${tempDir.path}/enhanced_${DateTime.now().millisecondsSinceEpoch}.mp4');
final listFile = File('${tempDir.path}/segments_list.txt');
// Build FFmpeg filter chain
List<String> filters = [];
// 1. Speed filter
if (playbackSpeed != 1.0) {
filters.add('setpts=${1.0/playbackSpeed}*PTS');
filters.add('atempo=${playbackSpeed}');
final buffer = StringBuffer();
for (final segment in segments) {
// FFmpeg requires safe paths (escaping special chars might be needed, but usually basic paths are fine)
// IMPORTANT: pathways in list file for concat demuxer must be absolute.
buffer.writeln("file '${segment.path}'");
}
await listFile.writeAsString(buffer.toString());
// 2. Visual filters
switch (filter) {
case 'grayscale':
filters.add('colorchannelmixer=.299:.587:.114:0:.299:.587:.114:0:.299:.587:.114');
break;
case 'sepia':
filters.add('colorchannelmixer=.393:.769:.189:0:.349:.686:.168:0:.272:.534:.131');
break;
case 'vintage':
filters.add('curves=vintage');
break;
case 'cold':
filters.add('colorbalance=rs=-0.1:gs=0.05:bs=0.2');
break;
case 'warm':
filters.add('colorbalance=rs=0.2:gs=0.05:bs=-0.1');
break;
case 'dramatic':
filters.add('contrast=1.5:brightness=-0.1:saturation=1.2');
break;
}
// 2. Define output path
final outputFile = File('${tempDir.path}/stitched_${DateTime.now().millisecondsSinceEpoch}.mp4');
// 3. Text overlay
if (textOverlay != null && textOverlay!['text'].toString().isNotEmpty) {
final text = textOverlay!['text'];
final size = (textOverlay!['size'] as double).toInt();
final color = textOverlay!['color'];
final position = (textOverlay!['position'] as double);
// Position: 0.0 = top, 1.0 = bottom
final yPos = position == 0.0 ? 'h-th' : 'h-h';
filters.add("drawtext=text='$text':fontsize=$size:fontcolor=$color:x=(w-text_w)/2:y=$yPos:enable='between(t,0,30)'");
}
// Combine all filters
String filterString = '';
if (filters.isNotEmpty) {
filterString = '-vf "${filters.join(',')}"';
}
// Build FFmpeg command
String command;
if (segments.length == 1) {
// Single video with effects
command = "-i '${segments.first.path}' $filterString -map_metadata -1 '${outputFile.path}'";
} else {
// Multiple videos - stitch first, then apply effects
final listFile = File('${tempDir.path}/segments_list.txt');
final buffer = StringBuffer();
for (final segment in segments) {
buffer.writeln("file '${segment.path}'");
}
await listFile.writeAsString(buffer.toString());
final tempStitched = File('${tempDir.path}/temp_stitched.mp4');
// First stitch without effects (metadata stripped at final pass)
final stitchCommand = "-f concat -safe 0 -i '${listFile.path}' -c copy '${tempStitched.path}'";
final stitchSession = await FFmpegKit.execute(stitchCommand);
final stitchReturnCode = await stitchSession.getReturnCode();
if (!ReturnCode.isSuccess(stitchReturnCode)) {
return null;
}
// Then apply effects to the stitched video, stripping metadata at final output
command = "-i '${tempStitched.path}' $filterString -map_metadata -1 '${outputFile.path}'";
}
// 3. Execute FFmpeg command
// -f concat: format
// -safe 0: allow unsafe paths (required for absolute paths)
// -i listFile: input list
// -c copy: stream copy (fast, no re-encoding)
final command = "-f concat -safe 0 -i '${listFile.path}' -c copy '${outputFile.path}'";
final session = await FFmpegKit.execute(command);
final returnCode = await session.getReturnCode();
if (!ReturnCode.isSuccess(returnCode)) {
if (ReturnCode.isSuccess(returnCode)) {
return outputFile;
} else {
// Fallback: return the last segment or first one to at least save something?
// For strict correctness, return null or throw.
// Let's print logs.
final logs = await session.getOutput();
print('FFmpeg error: $logs');
return null;
}
// Audio overlay pass (optional second FFmpeg call to mix in background audio)
if (audioOverlayPath != null && audioOverlayPath.isNotEmpty) {
final audioOutputFile = File('${tempDir.path}/audio_${DateTime.now().millisecondsSinceEpoch}.mp4');
final vol = audioVolume.clamp(0.0, 1.0).toStringAsFixed(2);
final audioCmd =
"-i '${outputFile.path}' -i '$audioOverlayPath' "
"-filter_complex '[1:a]volume=${vol}[a1];[0:a][a1]amix=inputs=2:duration=first:dropout_transition=0' "
"-map_metadata -1 -c:v copy -shortest '${audioOutputFile.path}'";
final audioSession = await FFmpegKit.execute(audioCmd);
final audioCode = await audioSession.getReturnCode();
if (ReturnCode.isSuccess(audioCode)) {
return audioOutputFile;
}
// If audio mix fails, fall through and return the video without the overlay
print('Audio overlay mix failed — returning video without audio overlay');
}
return outputFile;
} catch (e) {
print('Video stitching error: $e');
return null;
}
}
/// Legacy method for backward compatibility
static Future<File?> stitchVideosLegacy(List<File> segments) async {
return stitchVideos(segments, [], 'none', 1.0, null);
}
}

View file

@ -1,87 +0,0 @@
import 'dart:io';
import 'dart:async';
import 'package:flutter/material.dart';
/// Global error handler for consistent error messaging and logging
class ErrorHandler {
/// Handle an error and optionally show a snackbar to the user
static void handleError(
dynamic error, {
required BuildContext context,
String? userMessage,
bool showSnackbar = true,
}) {
final displayMessage = _getDisplayMessage(error, userMessage);
// Log to console (in production, send to analytics/crash reporting)
_logError(error, displayMessage);
if (showSnackbar && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(displayMessage),
action: SnackBarAction(
label: 'Dismiss',
onPressed: () {},
),
duration: const Duration(seconds: 4),
backgroundColor: Colors.red[700],
),
);
}
}
/// Get user-friendly error message
static String _getDisplayMessage(dynamic error, String? userMessage) {
if (userMessage != null) return userMessage;
if (error is SocketException) {
return 'No internet connection. Please check your network.';
} else if (error is TimeoutException) {
return 'Request timed out. Please try again.';
} else if (error is FormatException) {
return 'Invalid data format received.';
} else if (error.toString().contains('401')) {
return 'Authentication error. Please sign in again.';
} else if (error.toString().contains('403')) {
return 'You don\'t have permission to do that.';
} else if (error.toString().contains('404')) {
return 'Resource not found.';
} else if (error.toString().contains('500')) {
return 'Server error. Please try again later.';
} else {
return 'Something went wrong. Please try again.';
}
}
/// Log error for debugging/analytics
static void _logError(dynamic error, String message) {
// In production, send to Sentry, Firebase Crashlytics, etc.
debugPrint('ERROR: $message');
debugPrint('Details: ${error.toString()}');
if (error is Error) {
debugPrint('Stack trace: ${error.stackTrace}');
}
}
}
/// Wrapper for async operations with automatic error handling
Future<T?> safeExecute<T>({
required Future<T> Function() operation,
required BuildContext context,
String? errorMessage,
bool showError = true,
}) async {
try {
return await operation();
} catch (e) {
if (showError) {
ErrorHandler.handleError(
e,
context: context,
userMessage: errorMessage,
);
}
return null;
}
}

Some files were not shown because too many files have changed in this diff Show more