feat: Add capsule entry reporting system with image proxy for external GIFs
- Add capsule_reports table and admin UI: new /capsule-reports page with list/update handlers - POST /capsules/:id/entries/:entryId/report: members can report encrypted entries with voluntary decrypted sample - GET /image-proxy: stream external images (Reddit, GifCities) through server to hide client IPs - group_chat_tab: add GIF picker, report button, proxy external GIF URLs through ApiConfig.proxyImageUrl() - api.ts: add list
This commit is contained in:
parent
93a2c45a92
commit
c98c73f724
161
admin/src/app/capsule-reports/page.tsx
Normal file
161
admin/src/app/capsule-reports/page.tsx
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
'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>
|
||||
);
|
||||
}
|
||||
|
|
@ -45,6 +45,7 @@ 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 },
|
||||
],
|
||||
|
|
|
|||
|
|
@ -232,6 +232,22 @@ 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');
|
||||
|
|
|
|||
|
|
@ -404,6 +404,7 @@ func main() {
|
|||
// Media routes
|
||||
authorized.POST("/upload", mediaHandler.Upload)
|
||||
authorized.GET("/media/sign", mediaHandler.GetSignedMediaURL)
|
||||
authorized.GET("/image-proxy", mediaHandler.ImageProxy)
|
||||
|
||||
// Search & Discover routes
|
||||
discoverHandler := handlers.NewDiscoverHandler(userRepo, postRepo, tagRepo, categoryRepo, assetService)
|
||||
|
|
@ -529,6 +530,7 @@ func main() {
|
|||
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)
|
||||
|
|
@ -644,6 +646,10 @@ 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)
|
||||
|
|
|
|||
|
|
@ -1726,6 +1726,89 @@ 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
|
||||
// ──────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -761,3 +761,64 @@ 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"})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -268,3 +268,67 @@ 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/",
|
||||
} {
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,4 +28,18 @@ 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/');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
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/gif/gif_picker.dart';
|
||||
|
||||
class GroupChatTab extends StatefulWidget {
|
||||
final String groupId;
|
||||
|
|
@ -30,6 +33,7 @@ class _GroupChatTabState extends State<GroupChatTab> {
|
|||
List<Map<String, dynamic>> _messages = [];
|
||||
bool _loading = true;
|
||||
bool _sending = false;
|
||||
String? _pendingGif; // GIF URL staged before send
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
|
|
@ -84,6 +88,7 @@ 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({
|
||||
|
|
@ -100,35 +105,45 @@ class _GroupChatTabState extends State<GroupChatTab> {
|
|||
|
||||
Future<void> _sendMessage() async {
|
||||
final text = _msgCtrl.text.trim();
|
||||
if (text.isEmpty || _sending) return;
|
||||
final gif = _pendingGif;
|
||||
if (text.isEmpty && gif == null) return;
|
||||
if (_sending) return;
|
||||
|
||||
// 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),
|
||||
);
|
||||
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),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
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),
|
||||
);
|
||||
// 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;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _sending = true);
|
||||
try {
|
||||
final payload = {
|
||||
'text': text,
|
||||
'ts': DateTime.now().toIso8601String(),
|
||||
if (gif != null) 'gif_url': gif,
|
||||
};
|
||||
|
||||
if (widget.isEncrypted && widget.capsuleKey != null) {
|
||||
final encrypted = await CapsuleSecurityService.encryptPayload(
|
||||
payload: {'text': text, 'ts': DateTime.now().toIso8601String()},
|
||||
payload: payload,
|
||||
capsuleKey: widget.capsuleKey!,
|
||||
);
|
||||
await ApiService.instance.callGoApi(
|
||||
|
|
@ -142,9 +157,11 @@ class _GroupChatTabState extends State<GroupChatTab> {
|
|||
},
|
||||
);
|
||||
} else {
|
||||
await ApiService.instance.sendGroupMessage(widget.groupId, body: text);
|
||||
await ApiService.instance.sendGroupMessage(widget.groupId,
|
||||
body: text.isNotEmpty ? text : gif ?? '');
|
||||
}
|
||||
_msgCtrl.clear();
|
||||
setState(() => _pendingGif = null);
|
||||
await _loadMessages();
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
|
|
@ -154,6 +171,99 @@ class _GroupChatTabState extends State<GroupChatTab> {
|
|||
if (mounted) setState(() => _sending = false);
|
||||
}
|
||||
|
||||
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 {
|
||||
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.')),
|
||||
);
|
||||
}
|
||||
} catch (_) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Could not submit report. Please try again.')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _scrollToBottom() {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (_scrollCtrl.hasClients) {
|
||||
|
|
@ -213,6 +323,10 @@ 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,
|
||||
);
|
||||
},
|
||||
),
|
||||
|
|
@ -220,44 +334,97 @@ class _GroupChatTabState extends State<GroupChatTab> {
|
|||
),
|
||||
// Compose bar
|
||||
Container(
|
||||
padding: const EdgeInsets.fromLTRB(12, 8, 8, 12),
|
||||
padding: const EdgeInsets.fromLTRB(12, 6, 8, 12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.cardSurface,
|
||||
border: Border(top: BorderSide(color: AppTheme.navyBlue.withValues(alpha: 0.08))),
|
||||
),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: Row(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
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),
|
||||
// GIF preview
|
||||
if (_pendingGif != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 6),
|
||||
child: Stack(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: Image.network(
|
||||
ApiConfig.needsProxy(_pendingGif!)
|
||||
? ApiConfig.proxyImageUrl(_pendingGif!)
|
||||
: _pendingGif!,
|
||||
height: 100, fit: BoxFit.cover),
|
||||
),
|
||||
Positioned(
|
||||
top: 4, right: 4,
|
||||
child: GestureDetector(
|
||||
onTap: () => setState(() => _pendingGif = null),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Colors.black54,
|
||||
),
|
||||
padding: const EdgeInsets.all(2),
|
||||
child: const Icon(Icons.close, color: Colors.white, size: 14),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
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,
|
||||
Row(
|
||||
children: [
|
||||
// GIF button
|
||||
GestureDetector(
|
||||
onTap: () => showGifPicker(
|
||||
context,
|
||||
onSelected: (url) => setState(() => _pendingGif = url),
|
||||
),
|
||||
child: Container(
|
||||
width: 36, height: 36,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: AppTheme.navyBlue.withValues(alpha: 0.07),
|
||||
),
|
||||
child: Icon(Icons.gif_outlined, color: AppTheme.textSecondary, size: 22),
|
||||
),
|
||||
),
|
||||
child: _sending
|
||||
? const Padding(padding: EdgeInsets.all(10), child: CircularProgressIndicator(strokeWidth: 2, color: SojornColors.basicWhite))
|
||||
: const Icon(Icons.send, color: SojornColors.basicWhite, size: 18),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -274,12 +441,16 @@ 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
|
||||
|
|
@ -290,7 +461,9 @@ class _ChatBubble extends StatelessWidget {
|
|||
|
||||
return Align(
|
||||
alignment: isMine ? Alignment.centerRight : Alignment.centerLeft,
|
||||
child: Container(
|
||||
child: GestureDetector(
|
||||
onLongPress: onReport,
|
||||
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),
|
||||
|
|
@ -334,9 +507,30 @@ class _ChatBubble extends StatelessWidget {
|
|||
],
|
||||
),
|
||||
),
|
||||
Text(body, style: TextStyle(color: SojornColors.postContent, fontSize: 14, height: 1.35)),
|
||||
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(),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,14 @@
|
|||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:cryptography/cryptography.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import '../../config/api_config.dart';
|
||||
import '../../services/api_service.dart';
|
||||
import '../../services/capsule_security_service.dart';
|
||||
import '../../services/image_upload_service.dart';
|
||||
import '../../theme/tokens.dart';
|
||||
import '../../theme/app_theme.dart';
|
||||
import '../../widgets/gif/gif_picker.dart';
|
||||
|
||||
class GroupFeedTab extends StatefulWidget {
|
||||
final String groupId;
|
||||
|
|
@ -29,6 +34,11 @@ class _GroupFeedTabState extends State<GroupFeedTab> {
|
|||
bool _loading = true;
|
||||
bool _posting = false;
|
||||
|
||||
// Image / GIF attachment (public groups only)
|
||||
File? _pickedImage;
|
||||
String? _pendingImageUrl; // already-uploaded URL (from GIF or uploaded file)
|
||||
bool _uploading = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
|
@ -41,6 +51,35 @@ class _GroupFeedTabState extends State<GroupFeedTab> {
|
|||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _pickImage() async {
|
||||
final picker = ImagePicker();
|
||||
final xf = await picker.pickImage(source: ImageSource.gallery);
|
||||
if (xf == null) return;
|
||||
setState(() { _pickedImage = File(xf.path); _pendingImageUrl = null; });
|
||||
}
|
||||
|
||||
void _attachGif(String gifUrl) {
|
||||
setState(() { _pickedImage = null; _pendingImageUrl = gifUrl; });
|
||||
}
|
||||
|
||||
void _clearAttachment() {
|
||||
setState(() { _pickedImage = null; _pendingImageUrl = null; });
|
||||
}
|
||||
|
||||
Future<String?> _resolveImageUrl() async {
|
||||
if (_pendingImageUrl != null) return _pendingImageUrl;
|
||||
if (_pickedImage != null) {
|
||||
setState(() => _uploading = true);
|
||||
try {
|
||||
final url = await ImageUploadService().uploadImage(_pickedImage!);
|
||||
return url;
|
||||
} finally {
|
||||
if (mounted) setState(() => _uploading = false);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<void> _loadPosts() async {
|
||||
setState(() => _loading = true);
|
||||
try {
|
||||
|
|
@ -99,7 +138,8 @@ class _GroupFeedTabState extends State<GroupFeedTab> {
|
|||
|
||||
Future<void> _createPost() async {
|
||||
final text = _postCtrl.text.trim();
|
||||
if (text.isEmpty || _posting) return;
|
||||
final hasAttachment = _pickedImage != null || _pendingImageUrl != null;
|
||||
if ((text.isEmpty && !hasAttachment) || _posting) return;
|
||||
setState(() => _posting = true);
|
||||
try {
|
||||
if (widget.isEncrypted && widget.capsuleKey != null) {
|
||||
|
|
@ -118,9 +158,15 @@ class _GroupFeedTabState extends State<GroupFeedTab> {
|
|||
},
|
||||
);
|
||||
} else {
|
||||
await ApiService.instance.createGroupPost(widget.groupId, body: text);
|
||||
final imageUrl = await _resolveImageUrl();
|
||||
await ApiService.instance.createGroupPost(
|
||||
widget.groupId,
|
||||
body: text,
|
||||
imageUrl: imageUrl,
|
||||
);
|
||||
}
|
||||
_postCtrl.clear();
|
||||
_clearAttachment();
|
||||
await _loadPosts();
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
|
|
@ -163,51 +209,103 @@ class _GroupFeedTabState extends State<GroupFeedTab> {
|
|||
children: [
|
||||
// Composer
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
padding: const EdgeInsets.fromLTRB(12, 10, 8, 10),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.cardSurface,
|
||||
border: Border(bottom: BorderSide(color: AppTheme.navyBlue.withValues(alpha: 0.06))),
|
||||
),
|
||||
child: Row(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
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),
|
||||
Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 18,
|
||||
backgroundColor: AppTheme.brightNavy.withValues(alpha: 0.1),
|
||||
child: Icon(Icons.person, size: 18, color: AppTheme.brightNavy),
|
||||
),
|
||||
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,
|
||||
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.newline,
|
||||
maxLines: null,
|
||||
),
|
||||
),
|
||||
child: _posting
|
||||
? const Padding(padding: EdgeInsets.all(9), child: CircularProgressIndicator(strokeWidth: 2, color: SojornColors.basicWhite))
|
||||
: const Icon(Icons.send, color: SojornColors.basicWhite, size: 16),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
GestureDetector(
|
||||
onTap: (_posting || _uploading) ? null : _createPost,
|
||||
child: Container(
|
||||
width: 36, height: 36,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: (_posting || _uploading)
|
||||
? AppTheme.brightNavy.withValues(alpha: 0.5)
|
||||
: AppTheme.brightNavy,
|
||||
),
|
||||
child: (_posting || _uploading)
|
||||
? const Padding(padding: EdgeInsets.all(9), child: CircularProgressIndicator(strokeWidth: 2, color: SojornColors.basicWhite))
|
||||
: const Icon(Icons.send, color: SojornColors.basicWhite, size: 16),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
// Attachment buttons (public groups only) + preview
|
||||
if (!widget.isEncrypted) ...[
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
_MediaBtn(
|
||||
icon: Icons.image_outlined,
|
||||
label: 'Photo',
|
||||
onTap: _pickImage,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_MediaBtn(
|
||||
icon: Icons.gif_outlined,
|
||||
label: 'GIF',
|
||||
onTap: () => showGifPicker(context, onSelected: _attachGif),
|
||||
),
|
||||
if (_pickedImage != null || _pendingImageUrl != null) ...[
|
||||
const Spacer(),
|
||||
GestureDetector(
|
||||
onTap: _clearAttachment,
|
||||
child: Icon(Icons.cancel, size: 18, color: AppTheme.textSecondary),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
// Attachment preview
|
||||
if (_pickedImage != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: Image.file(_pickedImage!, height: 120, fit: BoxFit.cover),
|
||||
),
|
||||
),
|
||||
if (_pendingImageUrl != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: Image.network(
|
||||
ApiConfig.needsProxy(_pendingImageUrl!)
|
||||
? ApiConfig.proxyImageUrl(_pendingImageUrl!)
|
||||
: _pendingImageUrl!,
|
||||
height: 120, fit: BoxFit.cover),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
@ -332,8 +430,12 @@ class _PostCard extends StatelessWidget {
|
|||
const SizedBox(height: 10),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: Image.network(imageUrl, fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) => const SizedBox.shrink()),
|
||||
child: Image.network(
|
||||
ApiConfig.needsProxy(imageUrl)
|
||||
? ApiConfig.proxyImageUrl(imageUrl)
|
||||
: imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) => const SizedBox.shrink()),
|
||||
),
|
||||
],
|
||||
// Actions
|
||||
|
|
@ -499,3 +601,36 @@ class _CommentsSheetState extends State<_CommentsSheet> {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MediaBtn extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final VoidCallback onTap;
|
||||
const _MediaBtn({required this.icon, required this.label, required this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.navyBlue.withValues(alpha: 0.06),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 16, color: AppTheme.textSecondary),
|
||||
const SizedBox(width: 4),
|
||||
Text(label,
|
||||
style: TextStyle(
|
||||
color: AppTheme.textSecondary,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ class _GroupForumTabState extends State<GroupForumTab> {
|
|||
static const _subforums = ['General', 'Events', 'Information', 'Safety', 'Recommendations', 'Marketplace'];
|
||||
|
||||
static const _subforumDescriptions = {
|
||||
'General': 'Open neighborhood discussion',
|
||||
'General': 'Open public 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 neighborhood forums support sub-forums via category.
|
||||
// Non-encrypted public forums support sub-forums via category.
|
||||
final queryParams = <String, String>{
|
||||
'limit': _activeSubforum == null ? '120' : '30',
|
||||
};
|
||||
|
|
|
|||
509
sojorn_app/lib/widgets/gif/gif_picker.dart
Normal file
509
sojorn_app/lib/widgets/gif/gif_picker.dart
Normal file
|
|
@ -0,0 +1,509 @@
|
|||
import 'dart:convert';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import '../../config/api_config.dart';
|
||||
import '../../theme/app_theme.dart';
|
||||
import '../../theme/tokens.dart';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Public entry point
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Shows the GIF picker as a modal bottom sheet.
|
||||
/// Calls [onSelected] with the chosen GIF URL and closes the sheet.
|
||||
Future<void> showGifPicker(
|
||||
BuildContext context, {
|
||||
required void Function(String gifUrl) onSelected,
|
||||
}) {
|
||||
return showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (_) => _GifPickerSheet(onSelected: onSelected),
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Sheet
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class _GifPickerSheet extends StatefulWidget {
|
||||
final void Function(String gifUrl) onSelected;
|
||||
const _GifPickerSheet({required this.onSelected});
|
||||
|
||||
@override
|
||||
State<_GifPickerSheet> createState() => _GifPickerSheetState();
|
||||
}
|
||||
|
||||
class _GifPickerSheetState extends State<_GifPickerSheet>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final TabController _tabs;
|
||||
final _memesSearch = TextEditingController();
|
||||
final _retroSearch = TextEditingController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tabs = TabController(length: 2, vsync: this);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tabs.dispose();
|
||||
_memesSearch.dispose();
|
||||
_retroSearch.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DraggableScrollableSheet(
|
||||
initialChildSize: 0.87,
|
||||
minChildSize: 0.5,
|
||||
maxChildSize: 0.95,
|
||||
builder: (ctx, scrollCtrl) => Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.cardSurface,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// Drag handle
|
||||
Container(
|
||||
margin: const EdgeInsets.only(top: 10),
|
||||
width: 36,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.navyBlue.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// Header
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
Text('GIFs',
|
||||
style: TextStyle(
|
||||
color: AppTheme.navyBlue,
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w700)),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
icon: Icon(Icons.close,
|
||||
color: AppTheme.textSecondary, size: 20),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Tabs
|
||||
Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.navyBlue.withValues(alpha: 0.06),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: TabBar(
|
||||
controller: _tabs,
|
||||
indicator: BoxDecoration(
|
||||
color: AppTheme.brightNavy,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
labelColor: SojornColors.basicWhite,
|
||||
unselectedLabelColor: AppTheme.textSecondary,
|
||||
labelStyle: const TextStyle(
|
||||
fontSize: 12, fontWeight: FontWeight.w600),
|
||||
indicatorSize: TabBarIndicatorSize.tab,
|
||||
tabs: const [
|
||||
Tab(text: 'MEMES'),
|
||||
Tab(text: 'RETRO'),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
controller: _tabs,
|
||||
children: [
|
||||
_MemeTab(
|
||||
searchCtrl: _memesSearch,
|
||||
onSelected: (url) {
|
||||
Navigator.of(context).pop();
|
||||
widget.onSelected(url);
|
||||
},
|
||||
),
|
||||
_RetroTab(
|
||||
searchCtrl: _retroSearch,
|
||||
onSelected: (url) {
|
||||
Navigator.of(context).pop();
|
||||
widget.onSelected(url);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Memes tab — Reddit meme_api (r/gifs, r/reactiongifs, r/HighQualityGifs)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class _MemeTab extends StatefulWidget {
|
||||
final TextEditingController searchCtrl;
|
||||
final void Function(String url) onSelected;
|
||||
const _MemeTab({required this.searchCtrl, required this.onSelected});
|
||||
|
||||
@override
|
||||
State<_MemeTab> createState() => _MemeTabState();
|
||||
}
|
||||
|
||||
class _MemeTabState extends State<_MemeTab>
|
||||
with AutomaticKeepAliveClientMixin {
|
||||
List<_GifItem> _gifs = [];
|
||||
bool _loading = true;
|
||||
bool _hasError = false;
|
||||
String _loadedQuery = '';
|
||||
|
||||
static const _defaultSubreddits = ['gifs', 'reactiongifs', 'HighQualityGifs'];
|
||||
|
||||
@override
|
||||
bool get wantKeepAlive => true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_fetch('');
|
||||
widget.searchCtrl.addListener(_onSearchChanged);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.searchCtrl.removeListener(_onSearchChanged);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onSearchChanged() {
|
||||
final q = widget.searchCtrl.text.trim();
|
||||
if (q != _loadedQuery) {
|
||||
_fetch(q);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _fetch(String query) async {
|
||||
if (!mounted) return;
|
||||
setState(() { _loading = true; _hasError = false; });
|
||||
_loadedQuery = query;
|
||||
|
||||
try {
|
||||
final results = <_GifItem>[];
|
||||
if (query.isEmpty) {
|
||||
// Load from three subreddits in parallel
|
||||
final futures = _defaultSubreddits.map(_fetchSubreddit);
|
||||
final lists = await Future.wait(futures);
|
||||
for (final list in lists) {
|
||||
results.addAll(list);
|
||||
}
|
||||
results.shuffle();
|
||||
} else {
|
||||
// Try the query as a subreddit name
|
||||
results.addAll(await _fetchSubreddit(query));
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_gifs = results.take(60).toList();
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
} catch (_) {
|
||||
if (mounted) setState(() { _loading = false; _hasError = true; });
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<_GifItem>> _fetchSubreddit(String subreddit) async {
|
||||
final uri = Uri.parse(
|
||||
'https://meme-api.com/gimme/$subreddit/20');
|
||||
final resp = await http.get(uri).timeout(const Duration(seconds: 8));
|
||||
if (resp.statusCode != 200) return [];
|
||||
final data = jsonDecode(resp.body) as Map<String, dynamic>;
|
||||
final memes = (data['memes'] as List?) ?? [];
|
||||
return memes
|
||||
.cast<Map<String, dynamic>>()
|
||||
.where((m) {
|
||||
final url = m['url'] as String? ?? '';
|
||||
return url.endsWith('.gif') && m['nsfw'] != true;
|
||||
})
|
||||
.map((m) => _GifItem(
|
||||
url: m['url'] as String,
|
||||
title: m['title'] as String? ?? '',
|
||||
))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
super.build(context);
|
||||
return Column(
|
||||
children: [
|
||||
_SearchBar(
|
||||
ctrl: widget.searchCtrl,
|
||||
hint: 'Search by subreddit (e.g. dogs, gaming)…',
|
||||
),
|
||||
Expanded(child: _GifGrid(
|
||||
gifs: _gifs,
|
||||
loading: _loading,
|
||||
hasError: _hasError,
|
||||
emptyMessage: _loadedQuery.isEmpty
|
||||
? 'No GIFs found'
|
||||
: 'No GIFs in r/${widget.searchCtrl.text.trim()}',
|
||||
onSelected: widget.onSelected,
|
||||
onRetry: () => _fetch(_loadedQuery),
|
||||
)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Retro tab — GifCities (archive.org GeoCities GIFs)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class _RetroTab extends StatefulWidget {
|
||||
final TextEditingController searchCtrl;
|
||||
final void Function(String url) onSelected;
|
||||
const _RetroTab({required this.searchCtrl, required this.onSelected});
|
||||
|
||||
@override
|
||||
State<_RetroTab> createState() => _RetroTabState();
|
||||
}
|
||||
|
||||
class _RetroTabState extends State<_RetroTab>
|
||||
with AutomaticKeepAliveClientMixin {
|
||||
List<_GifItem> _gifs = [];
|
||||
bool _loading = true;
|
||||
bool _hasError = false;
|
||||
String _loadedQuery = '';
|
||||
|
||||
static const _defaultQuery = 'space';
|
||||
static final _gifUrlRegex = RegExp(
|
||||
r'https://blob\.gifcities\.org/gifcities/[A-Z0-9]+\.gif');
|
||||
|
||||
@override
|
||||
bool get wantKeepAlive => true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_fetch(_defaultQuery);
|
||||
widget.searchCtrl.addListener(_onSearchChanged);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.searchCtrl.removeListener(_onSearchChanged);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onSearchChanged() {
|
||||
final q = widget.searchCtrl.text.trim();
|
||||
final effective = q.isEmpty ? _defaultQuery : q;
|
||||
if (effective != _loadedQuery) {
|
||||
_fetch(effective);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _fetch(String query) async {
|
||||
if (!mounted) return;
|
||||
setState(() { _loading = true; _hasError = false; });
|
||||
_loadedQuery = query;
|
||||
|
||||
try {
|
||||
final uri = Uri.parse(
|
||||
'https://gifcities.org/search?q=${Uri.encodeComponent(query)}&page_size=60&offset=0');
|
||||
final resp = await http.get(
|
||||
uri,
|
||||
headers: {'Accept': 'text/html,*/*'},
|
||||
).timeout(const Duration(seconds: 10));
|
||||
|
||||
final matches = _gifUrlRegex.allMatches(resp.body);
|
||||
final unique = <String>{};
|
||||
final gifs = <_GifItem>[];
|
||||
for (final m in matches) {
|
||||
final url = m.group(0)!;
|
||||
if (unique.add(url)) {
|
||||
gifs.add(_GifItem(url: url, title: ''));
|
||||
}
|
||||
}
|
||||
|
||||
if (mounted) setState(() { _gifs = gifs; _loading = false; });
|
||||
} catch (_) {
|
||||
if (mounted) setState(() { _loading = false; _hasError = true; });
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
super.build(context);
|
||||
return Column(
|
||||
children: [
|
||||
_SearchBar(
|
||||
ctrl: widget.searchCtrl,
|
||||
hint: 'Search retro GIFs (e.g. dancing, stars)…',
|
||||
),
|
||||
Expanded(child: _GifGrid(
|
||||
gifs: _gifs,
|
||||
loading: _loading,
|
||||
hasError: _hasError,
|
||||
emptyMessage: 'No retro GIFs found for "${widget.searchCtrl.text.trim().isEmpty ? _defaultQuery : widget.searchCtrl.text.trim()}"',
|
||||
onSelected: widget.onSelected,
|
||||
onRetry: () => _fetch(_loadedQuery),
|
||||
)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Shared widgets
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class _SearchBar extends StatelessWidget {
|
||||
final TextEditingController ctrl;
|
||||
final String hint;
|
||||
const _SearchBar({required this.ctrl, required this.hint});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 8, 12, 8),
|
||||
child: TextField(
|
||||
controller: ctrl,
|
||||
style: TextStyle(color: AppTheme.navyBlue, fontSize: 14),
|
||||
decoration: InputDecoration(
|
||||
hintText: hint,
|
||||
hintStyle:
|
||||
TextStyle(color: AppTheme.textSecondary, fontSize: 13),
|
||||
prefixIcon: Icon(Icons.search,
|
||||
color: AppTheme.textSecondary, size: 20),
|
||||
suffixIcon: ValueListenableBuilder(
|
||||
valueListenable: ctrl,
|
||||
builder: (_, val, __) => val.text.isNotEmpty
|
||||
? IconButton(
|
||||
icon: Icon(Icons.clear,
|
||||
color: AppTheme.textSecondary, size: 18),
|
||||
onPressed: ctrl.clear,
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
filled: true,
|
||||
fillColor: AppTheme.navyBlue.withValues(alpha: 0.05),
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _GifItem {
|
||||
final String url;
|
||||
final String title;
|
||||
const _GifItem({required this.url, required this.title});
|
||||
}
|
||||
|
||||
class _GifGrid extends StatelessWidget {
|
||||
final List<_GifItem> gifs;
|
||||
final bool loading;
|
||||
final bool hasError;
|
||||
final String emptyMessage;
|
||||
final void Function(String url) onSelected;
|
||||
final VoidCallback onRetry;
|
||||
|
||||
const _GifGrid({
|
||||
required this.gifs,
|
||||
required this.loading,
|
||||
required this.hasError,
|
||||
required this.emptyMessage,
|
||||
required this.onSelected,
|
||||
required this.onRetry,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (loading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (hasError) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.wifi_off, size: 36, color: AppTheme.textSecondary),
|
||||
const SizedBox(height: 8),
|
||||
Text('Could not load GIFs',
|
||||
style: TextStyle(color: AppTheme.textSecondary, fontSize: 13)),
|
||||
const SizedBox(height: 12),
|
||||
TextButton(onPressed: onRetry, child: const Text('Retry')),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
if (gifs.isEmpty) {
|
||||
return Center(
|
||||
child: Text(emptyMessage,
|
||||
style: TextStyle(color: AppTheme.textSecondary, fontSize: 13),
|
||||
textAlign: TextAlign.center),
|
||||
);
|
||||
}
|
||||
return GridView.builder(
|
||||
padding: const EdgeInsets.all(8),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
crossAxisSpacing: 6,
|
||||
mainAxisSpacing: 6,
|
||||
childAspectRatio: 1.4,
|
||||
),
|
||||
itemCount: gifs.length,
|
||||
itemBuilder: (_, i) {
|
||||
final gif = gifs[i];
|
||||
final displayUrl = ApiConfig.needsProxy(gif.url)
|
||||
? ApiConfig.proxyImageUrl(gif.url)
|
||||
: gif.url;
|
||||
return GestureDetector(
|
||||
onTap: () => onSelected(gif.url), // store original URL, proxy at display
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: displayUrl,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (_, __) => Container(
|
||||
color: AppTheme.navyBlue.withValues(alpha: 0.05),
|
||||
child: Center(
|
||||
child: Icon(Icons.gif_outlined,
|
||||
color: AppTheme.textSecondary, size: 28),
|
||||
),
|
||||
),
|
||||
errorWidget: (_, __, ___) => Container(
|
||||
color: AppTheme.navyBlue.withValues(alpha: 0.05),
|
||||
child: Icon(Icons.broken_image_outlined,
|
||||
color: AppTheme.textSecondary),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue