13 KiB
Image Upload and Display Fix - January 8, 2025
Overview
Fixed a critical issue where images were uploading successfully to Cloudflare R2 but not displaying in the app feed. The root cause was that feed edge functions were filtering out image_url from API responses despite querying it from the database.
Problem Description
Symptoms
- Images uploaded successfully to Cloudflare R2
- Image URLs saved correctly to the
posts.image_urldatabase column - Images did NOT display in the app feed
- No error messages or visual indicators
Root Cause Analysis
The issue occurred in two edge functions (feed-sojorn and feed-personal) that manually construct JSON responses. While both functions included image_url in their SQL SELECT queries, they filtered it out when mapping the database results to the final JSON response sent to the Flutter app.
Example from feed-sojorn/index.ts (lines 110-115):
// SQL query INCLUDED image_url ✅
.select(`id, body, created_at, tone_label, allow_chain, chain_parent_id, image_url, ...`)
// BUT response mapping EXCLUDED it ❌
const orderedPosts = resultIds.map(...).map((post: Post) => ({
id: post.id,
body: post.body,
created_at: post.created_at,
// ... image_url was missing here!
}));
Files Modified
1. Backend Edge Functions
supabase/functions/feed-sojorn/index.ts
Changes:
- Line 13: Added
image_url: string | null;to thePostTypeScript interface - Line 112: Added
image_url: post.image_url,to the response mapping inorderedPosts
Before:
interface Post {
id: string; body: string; created_at: string; category_id: string;
tone_label: "positive" | "neutral" | "mixed" | "negative";
cis_score: number; author_id: string; author: Profile; category: Category;
metrics: PostMetrics | null; allow_chain: boolean; chain_parent_id: string | null;
user_liked: { user_id: string }[]; user_saved: { user_id: string }[];
}
const orderedPosts = resultIds.map((id) => finalPosts?.find((p: Post) => p.id === id)).filter(Boolean).map((post: Post) => ({
id: post.id, body: post.body, created_at: post.created_at, tone_label: post.tone_label, allow_chain: post.allow_chain,
chain_parent_id: post.chain_parent_id, author: post.author, category: post.category, metrics: post.metrics,
user_liked: post.user_liked?.some((l: PostLike) => l.user_id === user.id) || false,
user_saved: post.user_saved?.some((s: PostSave) => s.user_id === user.id) || false,
}));
After:
interface Post {
id: string; body: string; created_at: string; category_id: string;
tone_label: "positive" | "neutral" | "mixed" | "negative";
cis_score: number; author_id: string; author: Profile; category: Category;
metrics: PostMetrics | null; allow_chain: boolean; chain_parent_id: string | null;
image_url: string | null; // ✅ ADDED
user_liked: { user_id: string }[]; user_saved: { user_id: string }[];
}
const orderedPosts = resultIds.map((id) => finalPosts?.find((p: Post) => p.id === id)).filter(Boolean).map((post: Post) => ({
id: post.id, body: post.body, created_at: post.created_at, tone_label: post.tone_label, allow_chain: post.allow_chain,
chain_parent_id: post.chain_parent_id, image_url: post.image_url, // ✅ ADDED
author: post.author, category: post.category, metrics: post.metrics,
user_liked: post.user_liked?.some((l: PostLike) => l.user_id === user.id) || false,
user_saved: post.user_saved?.some((s: PostSave) => s.user_id === user.id) || false,
}));
supabase/functions/feed-personal/index.ts
Changes:
- Line 70: Added
image_url: post.image_url,to the response mapping infeedItems
Before:
const feedItems = postsWithChains.map((post: any) => ({
id: post.id, body: post.body, created_at: post.created_at, tone_label: post.tone_label,
allow_chain: post.allow_chain, chain_parent_id: post.chain_parent_id,
chain_parent: post.chain_parent_id ? chainParentMap.get(post.chain_parent_id) : null,
author: post.author, category: post.category, metrics: post.metrics,
user_liked: post.user_liked?.some((l: any) => l.user_id === user.id) || false,
user_saved: post.user_saved?.some((s: any) => s.user_id === user.id) || false,
}));
After:
const feedItems = postsWithChains.map((post: any) => ({
id: post.id, body: post.body, created_at: post.created_at, tone_label: post.tone_label,
allow_chain: post.allow_chain, chain_parent_id: post.chain_parent_id,
image_url: post.image_url, // ✅ ADDED
chain_parent: post.chain_parent_id ? chainParentMap.get(post.chain_parent_id) : null,
author: post.author, category: post.category, metrics: post.metrics,
user_liked: post.user_liked?.some((l: any) => l.user_id === user.id) || false,
user_saved: post.user_saved?.some((s: any) => s.user_id === user.id) || false,
}));
2. Frontend Flutter App
sojorn_app/lib/widgets/post/post_media.dart
Changes:
- Modified to accept and render
post.imageUrl - Added explicit height constraint (300px) to the image container
- Enhanced error handling with visual indicators
- Added loading state with progress indicator
Key improvements:
// Added post parameter to widget
class PostMedia extends StatelessWidget {
final Post? post;
final Widget? child;
const PostMedia({
super.key,
this.post,
this.child,
});
@override
Widget build(BuildContext context) {
// Render image if post has image_url
if (post != null && post!.imageUrl != null && post!.imageUrl!.isNotEmpty) {
return Padding(
padding: const EdgeInsets.only(top: AppTheme.spacingSm),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Debug banner (to be removed)
Container(
padding: const EdgeInsets.all(8),
color: Colors.blue,
width: double.infinity,
child: Text('IMAGE: ${post!.imageUrl}',
style: const TextStyle(color: Colors.white, fontSize: 8)),
),
const SizedBox(height: 8),
// Image with explicit height constraint
SizedBox(
height: 300,
width: double.infinity,
child: ClipRRect(
borderRadius: BorderRadius.circular(AppTheme.radiusMd),
child: Image.network(
post!.imageUrl!,
fit: BoxFit.cover,
loadingBuilder: (context, child, loadingProgress) {
if (loadingProgress == null) return child;
// Show loading indicator
return Container(
color: Colors.pink.withOpacity(0.3),
child: Center(child: CircularProgressIndicator(...)),
);
},
errorBuilder: (context, error, stackTrace) {
// Show error state
return Container(
color: Colors.red.withOpacity(0.3),
child: Center(
child: Column(
children: [
Icon(Icons.broken_image, size: 48),
Text('Error: $error'),
],
),
),
);
},
),
),
),
],
),
);
}
// ... fallback logic
}
}
sojorn_app/lib/widgets/post_card.dart
Changes:
- Line 66: Modified to pass
posttoPostMediawidget
Before:
PostHeader(post: post),
const SizedBox(height: AppTheme.spacingMd),
PostBody(text: post.body),
const PostMedia(), // ❌ Not receiving post data
const SizedBox(height: AppTheme.spacingMd),
After:
PostHeader(post: post),
const SizedBox(height: AppTheme.spacingMd),
PostBody(text: post.body),
PostMedia(post: post), // ✅ Now receives post data
const SizedBox(height: AppTheme.spacingMd),
Debugging Process
1. Initial Investigation
- Verified image upload functionality was working (images in R2 bucket ✅)
- Verified database had
image_urlvalues (4 posts with images ✅) - Confirmed edge function SELECT queries included
image_url(✅)
2. Discovery Phase
Added debug logging to trace data flow:
In Post.fromJson() (sojorn_app/lib/models/post.dart:120-126):
if (json['image_url'] != null) {
print('DEBUG Post.fromJson: Found image_url in JSON: ${json['image_url']}');
} else {
print('DEBUG Post.fromJson: No image_url in JSON for post ${json['id']}');
print('DEBUG Post.fromJson: Available keys: ${json.keys.toList()}');
}
In PostMedia widget (sojorn_app/lib/widgets/post/post_media.dart:19-24):
if (post != null) {
debugPrint('PostMedia: post.imageUrl = ${post!.imageUrl}');
}
if (post != null && post!.imageUrl != null && post!.imageUrl!.isNotEmpty) {
debugPrint('PostMedia: SHOWING IMAGE for ${post!.imageUrl}');
// ... render image
}
3. Key Finding
Console logs revealed two different response structures:
feed-sojorn response (missing image_url):
DEBUG Post.fromJson: No image_url in JSON for post f194f92b-...
Available keys: [id, body, created_at, tone_label, allow_chain,
chain_parent_id, author, category, metrics, user_liked, user_saved]
Other feed response (has image_url):
DEBUG Post.fromJson: Found image_url in JSON: https://media.sojorn.net/88a7cc72-...
Available keys: [id, body, author_id, category_id, tone_label, cis_score,
status, created_at, edited_at, deleted_at, allow_chain,
chain_parent_id, image_url, chain_parent, metrics, author]
This confirmed the edge functions were filtering out image_url.
Testing & Verification
Before Fix
I/flutter: DEBUG Post.fromJson: No image_url in JSON for post f194f92b-...
I/flutter: PostMedia: post.imageUrl = null
After Fix
I/flutter: DEBUG Post.fromJson: Found image_url in JSON: https://media.sojorn.net/88a7cc72-...
I/flutter: PostMedia: post.imageUrl = https://media.sojorn.net/88a7cc72-...
I/flutter: PostMedia: SHOWING IMAGE for https://media.sojorn.net/88a7cc72-...
I/flutter: PostMedia: Image loading... 8899 / 275401
I/flutter: PostMedia: Image LOADED successfully
Deployment
npx supabase functions deploy feed-sojorn feed-personal --no-verify-jwt
Note: The --no-verify-jwt flag is required because the app uses ES256 JWT tokens which are not compatible with the default Supabase edge function JWT validation.
Related Context
Image Upload Flow (Already Working)
- User selects image in
ComposeScreen - Image uploaded via
ImageUploadService.uploadImage()to Cloudflare R2 - Returns public URL:
https://media.sojorn.net/{uuid}.jpg - URL sent to
publish-postedge function - Saved to
posts.image_urlcolumn
Feed Display Flow (Now Fixed)
- App calls
getSojornFeed()orgetPersonalFeed() - Edge functions query database (includes
image_url) - [FIXED] Edge functions now include
image_urlin response JSON - Flutter
Post.fromJson()parsesimage_url PostCardpassesposttoPostMediaPostMediarenders image usingImage.network()
Cleanup Tasks
The following debug code should be removed in a future commit:
sojorn_app/lib/models/post.dart(lines 120-126): Remove debug logging inPost.fromJson()sojorn_app/lib/widgets/post/post_media.dart(lines 31-36): Remove blue debug bannersojorn_app/lib/widgets/post/post_media.dart(lines 19-20, 24, 48, 51, 64-65): Remove debug print statements
Lessons Learned
- Manual Response Mapping: When edge functions manually construct JSON responses (rather than returning raw database results), every field must be explicitly included
- Debug Logging: Adding strategic debug logs at data transformation boundaries (JSON parsing, API responses) quickly identified where data was being lost
- TypeScript Interfaces: TypeScript interfaces in edge functions should match the database schema to catch missing fields at compile time
- Widget Data Flow: Flutter widgets that display data must receive that data as parameters -
constconstructors without parameters cannot access dynamic data
Success Criteria
- ✅ Images upload to Cloudflare R2
- ✅ Image URLs save to database
- ✅ Feed APIs return
image_urlin JSON - ✅ Flutter app parses
image_urlfrom JSON - ✅ PostMedia widget receives post data
- ✅ Images display in app feed with loading states
- ✅ Error handling shows broken image icon on failure
Future Improvements
- Remove debug code and banners
- Consider using
AspectRatiowidget instead of fixed height for images - Add image caching to improve performance
- Implement progressive image loading with thumbnails
- Add image alt text support for accessibility