From 10ae2944d2da64fb999f31384dda0eee9ddd65dd Mon Sep 17 00:00:00 2001 From: Patrick Britton Date: Tue, 3 Feb 2026 17:13:28 -0600 Subject: [PATCH] feat: Initialize Sojorn Flutter application with core UI, services, E2EE, backup, and build scripts for various platforms. --- cloud_backup_status.md | 39 ++ feed_reactions_fix.md | 35 ++ run_dev.ps1 | 4 +- run_web.ps1 | 16 +- run_web_chrome.ps1 | 86 ++++ run_windows.ps1 | 111 +++++ run_windows_app.ps1 | 34 ++ sojorn_app/assets/reactions/dotto/credit.md | 10 +- .../reactions/dotto/download_dotto_emoji.py | 93 ----- sojorn_app/lib/models/post.dart | 10 + .../lib/screens/auth/sign_up_screen.dart | 2 +- sojorn_app/lib/screens/home/home_shell.dart | 12 +- .../post/threaded_conversation_screen.dart | 45 +- .../lib/screens/profile/profile_screen.dart | 79 +--- .../profile/profile_settings_screen.dart | 29 +- .../profile/viewable_profile_screen.dart | 216 +++++++++- .../secure_chat/secure_chat_full_screen.dart | 333 ++++++++++----- .../secure_chat/secure_chat_screen.dart | 84 ++-- sojorn_app/lib/services/api_service.dart | 107 ++++- .../services/local_key_backup_service.dart | 370 ++++++++++++++-- .../lib/services/local_message_store.dart | 38 ++ .../lib/services/simple_e2ee_service.dart | 83 ++-- sojorn_app/lib/widgets/app_scaffold.dart | 4 +- .../lib/widgets/chain_quote_widget.dart | 10 + .../lib/widgets/kinetic_thread_widget.dart | 11 + sojorn_app/lib/widgets/post/post_actions.dart | 26 +- .../lib/widgets/post_with_video_widget.dart | 6 +- .../widgets/reactions/reaction_picker.dart | 355 ++++++---------- .../lib/widgets/reactions/reaction_strip.dart | 24 +- .../widgets/reactions/reactions_display.dart | 281 ++++++++++--- .../reactions/smart_reaction_button.dart | 37 +- sojorn_app/lib/widgets/sojorn_app_bar.dart | 11 +- sojorn_app/pubspec.lock | 112 +++-- sojorn_app/pubspec.yaml | 10 +- sojorn_app/web/index.html | 2 +- sojorn_app/web/manifest.json | 4 +- sojorn_docs/E2EE_COMPREHENSIVE_GUIDE.md | 102 +++-- sojorn_docs/SOJORN_ARCHITECTURE.md | 395 ++++++++++++++++++ zero_knowledge_backup_summary.md | 36 ++ 39 files changed, 2443 insertions(+), 819 deletions(-) create mode 100644 cloud_backup_status.md create mode 100644 feed_reactions_fix.md create mode 100644 run_web_chrome.ps1 create mode 100644 run_windows.ps1 create mode 100644 run_windows_app.ps1 delete mode 100644 sojorn_app/assets/reactions/dotto/download_dotto_emoji.py create mode 100644 sojorn_docs/SOJORN_ARCHITECTURE.md create mode 100644 zero_knowledge_backup_summary.md diff --git a/cloud_backup_status.md b/cloud_backup_status.md new file mode 100644 index 0000000..02107c5 --- /dev/null +++ b/cloud_backup_status.md @@ -0,0 +1,39 @@ +# Cloud Backup Implementation Plan (Complete) + +## 1. Frontend Implementation (Flutter) +- [x] **ApiService Updates**: Added `uploadBackup` and `downloadBackup` methods to interact with the backend (endpoints `/backups/upload` and `/backups/download`). +- [x] **LocalKeyBackupService Refactor**: + - [x] Updated `createEncryptedBackup` to accept `includeKeys` and `includeMessages` flags. + - [x] Added `uploadToCloud` method which **defaults to Messages Only** (no keys) for security. + - [x] Added `restoreFromCloud` method to fetch and decrypt backups. +- [x] **UI Overhaul (LocalBackupScreen)**: + - [x] Added "Cloud Mode" vs "Local Mode" toggle. + - [x] Implemented "Zero Knowledge" warning UI when Cloud Mode is active (keys excluded by default). + - [x] Added visual cues for "Secure Mode". + - [x] Integrated `uploadToCloud` and `restoreFromCloud` calls with progress indicators and error handling. + +## 2. Backend Implementation (Go) +- [x] **Database Schema**: Created migration `000003_e2ee_backup_recovery.up.sql` for: + - `cloud_backups` table (stores encrypted blobs). + - `backup_preferences` table. + - `user_devices` table. + - `sync_codes` table. + - `recovery_guardians` and `recovery_sessions` tables (for future social recovery). +- [x] **API Endpoints**: + - `POST /backups/upload`: Accepts encrypted blob, metadata, and version. + - `GET /backups/download`: Retrieves latest backup. + - `GET /backups/download/:backup_id`: Retrieves specific backup. +- [x] **Data Models**: Defined `CloudBackup`, `UploadBackupRequest`, `DownloadBackupResponse` structs matching frontend expectations. +- [x] **Handler Logic**: Implemented "blind storage" logic - backend stores opaque blobs and does not attempt decryption. + +## 3. Deployment Status (Pending) +- [x] **Compilation**: Successfully compiled `sojorn-api-linux` and `migrate-linux` binaries locally. +- [ ] **Upload**: Failed to upload binaries to VPS (`194.238.28.122`) due to SSH authentication failure ("Permission denied") with provided credentials. +- [ ] **Migration**: Database migration failed from local machine due to port 5432 being closed/filtered. Needs to be run from the VPS. +- [ ] **Restart**: Service restart pending successful SSH access. + +## 4. Next Steps +Once SSH access is restored (verify password or add public key): +1. **Upload Binaries**: `scp sojorn-api-linux migrate-linux root@194.238.28.122:/root/` +2. **Run Migration**: `ssh root@... "./migrate-linux -path ... up"` +3. **Restart Service**: `ssh root@... "systemctl restart sojorn-api"` diff --git a/feed_reactions_fix.md b/feed_reactions_fix.md new file mode 100644 index 0000000..97f6d94 --- /dev/null +++ b/feed_reactions_fix.md @@ -0,0 +1,35 @@ +# Feed Reactions Fix - Implementation Summary + +## Status: COMPLETE & DEPLOYED ✅ + +### 1. Issue Description +- **Problem**: Reactions were not displaying on the "Following" (Home) and "Profile" feeds. The "Postcard" UI (specifically the compact method) was defaulting to the "Add Reaction" button because no reaction data was being returned by the API for these lists. +- **Root Cause**: The SQL queries for `GetFeed` and `GetPostsByAuthor` in the backend were not aggregating reaction data (counts and user choices), unlike the single-post endpoint. + +### 2. Implementation Details (Backend) +- **File**: `internal/repository/post_repository.go` +- **Changes**: + - Modified `GetFeed` SQL query to include a correlated subquery fetching `jsonb_object_agg(emoji, count)` for `reaction_counts`. + - Modified `GetPostsByAuthor` SQL query to do the same. + - Added logic to fetch `my_reactions` (the current user's votes) for both feeds. + - Updated the Go `Scan` destinations to populate the `Reactions` and `MyReactions` fields in the `Post` model. + - **Correction**: Also fixed a missing `allow_chain` and `visibility` selection in the `GetPostsByAuthor` query, ensuring consistency across the app. + +### 3. Frontend logic (Verified) +- **Widget**: `sojornPostCard` -> `PostActions` -> `ReactionsDisplay` +- **Logic**: The `ReactionsDisplay` widget in `compact` mode (used in feeds) is designed to: + 1. Show the user's reaction if they voted. + 2. Else, show the "Most Used" reaction from the community. + 3. Else (if valid data but count is 0), show the "Add" button. +- **Result**: Now that the backend provides the data (Case 1 or 2), the UI will correctly display the reaction chips instead of just the "+" button. + +### 4. Deployment +- **Server**: `194.238.28.122` +- **Action**: + - Compiled `sojorn-api-linux` locally. + - Uploaded to `/opt/sojorn/bin/api` via `scp` (user: `patrick`). + - Restarted `sojorn-api` service via `systemctl`. +- **Status**: API is active and serving the new queries. + +### 5. Next Steps +- **User Action**: Pull-to-refresh the feed in the Sojorn app to see the changes. diff --git a/run_dev.ps1 b/run_dev.ps1 index 89fa7f0..ffb9f93 100644 --- a/run_dev.ps1 +++ b/run_dev.ps1 @@ -26,7 +26,7 @@ Get-Content $EnvPath | ForEach-Object { $values[$key] = $value } -$required = @('SUPABASE_URL', 'SUPABASE_ANON_KEY', 'API_BASE_URL') +$required = @('API_BASE_URL') $missing = $required | Where-Object { -not $values.ContainsKey($_) -or [string]::IsNullOrWhiteSpace($values[$_]) } @@ -37,8 +37,6 @@ if ($missing.Count -gt 0) { } $defineArgs = @( - "--dart-define=SUPABASE_URL=$($values['SUPABASE_URL'])", - "--dart-define=SUPABASE_ANON_KEY=$($values['SUPABASE_ANON_KEY'])", "--dart-define=API_BASE_URL=$($values['API_BASE_URL'])" ) diff --git a/run_web.ps1 b/run_web.ps1 index e29b5b8..8361ea1 100644 --- a/run_web.ps1 +++ b/run_web.ps1 @@ -1,6 +1,6 @@ param( [string]$EnvPath = (Join-Path $PSScriptRoot ".env"), - [int]$Port = 8000, + [int]$Port = 8001, [string]$Renderer = "auto", # Options: auto, canvaskit, html [switch]$NoWasmDryRun ) @@ -8,7 +8,9 @@ param( function Parse-Env($path) { $vals = @{} if (-not (Test-Path $path)) { - Write-Host "No .env file found at ${path}. Falling back to defaults." -ForegroundColor Yellow + Write-Host "No .env file found at ${path}. Using defaults." -ForegroundColor Yellow + # Set default API_BASE_URL since no .env exists + $vals['API_BASE_URL'] = 'https://api.gosojorn.com/api/v1' return $vals } Get-Content $path | ForEach-Object { @@ -37,16 +39,20 @@ foreach ($k in $keysOfInterest) { } } -# Ensure API_BASE_URL always points to the public https endpoint. +# Ensure API_BASE_URL is set if (-not $values.ContainsKey('API_BASE_URL') -or [string]::IsNullOrWhiteSpace($values['API_BASE_URL'])) { - $defineArgs += "--dart-define=API_BASE_URL=https://api.gosojorn.com/api/v1" $currentApi = 'https://api.gosojorn.com/api/v1' + $defineArgs += "--dart-define=API_BASE_URL=$currentApi" } else { $currentApi = $values['API_BASE_URL'] + # Always ensure we're using the HTTPS endpoint if ($currentApi.StartsWith('http://api.gosojorn.com:8080')) { $currentApi = $currentApi.Replace('http://api.gosojorn.com:8080', 'https://api.gosojorn.com') $defineArgs = $defineArgs | Where-Object { -not ($_ -like '--dart-define=API_BASE_URL=*') } $defineArgs += "--dart-define=API_BASE_URL=$currentApi" + } elseif ($currentApi.StartsWith('http://localhost:')) { + # For local development, keep localhost but warn + Write-Host "Using local API: $currentApi" -ForegroundColor Yellow } } @@ -60,7 +66,7 @@ try { $cmdArgs = @( 'run', '-d', - 'chrome', + 'edge', '--web-hostname', 'localhost', '--web-port', diff --git a/run_web_chrome.ps1 b/run_web_chrome.ps1 new file mode 100644 index 0000000..70a996e --- /dev/null +++ b/run_web_chrome.ps1 @@ -0,0 +1,86 @@ +param( + [string]$EnvPath = (Join-Path $PSScriptRoot ".env"), + [int]$Port = 8002, + [string]$Renderer = "auto", # Options: auto, canvaskit, html + [switch]$NoWasmDryRun +) + +function Parse-Env($path) { + $vals = @{} + if (-not (Test-Path $path)) { + Write-Host "No .env file found at ${path}. Using defaults." -ForegroundColor Yellow + # Set default API_BASE_URL since no .env exists + $vals['API_BASE_URL'] = 'https://api.gosojorn.com/api/v1' + return $vals + } + Get-Content $path | ForEach-Object { + $line = $_.Trim() + if ($line.Length -eq 0 -or $line.StartsWith('#')) { return } + $parts = $line -split '=', 2 + if ($parts.Count -lt 2) { return } + $key = $parts[0].Trim() + $value = $parts[1].Trim() + if ($value.StartsWith('"') -and $value.EndsWith('"')) { + $value = $value.Substring(1, $value.Length - 2) + } + $vals[$key] = $value + } + return $vals +} + +$values = Parse-Env $EnvPath + +# Collect dart-defines we actually use on web. +$defineArgs = @() +$keysOfInterest = @('API_BASE_URL', 'FIREBASE_WEB_VAPID_KEY') +foreach ($k in $keysOfInterest) { + if ($values.ContainsKey($k) -and -not [string]::IsNullOrWhiteSpace($values[$k])) { + $defineArgs += "--dart-define=$k=$($values[$k])" + } +} + +# Ensure API_BASE_URL is set +if (-not $values.ContainsKey('API_BASE_URL') -or [string]::IsNullOrWhiteSpace($values['API_BASE_URL'])) { + $currentApi = 'https://api.gosojorn.com/api/v1' + $defineArgs += "--dart-define=API_BASE_URL=$currentApi" +} else { + $currentApi = $values['API_BASE_URL'] + # Always ensure we're using the HTTPS endpoint + if ($currentApi.StartsWith('http://api.gosojorn.com:8080')) { + $currentApi = $currentApi.Replace('http://api.gosojorn.com:8080', 'https://api.gosojorn.com') + $defineArgs = $defineArgs | Where-Object { -not ($_ -like '--dart-define=API_BASE_URL=*') } + $defineArgs += "--dart-define=API_BASE_URL=$currentApi" + } elseif ($currentApi.StartsWith('http://localhost:')) { + # For local development, keep localhost but warn + Write-Host "Using local API: $currentApi" -ForegroundColor Yellow + } +} + +Write-Host "Launching Sojorn Web (Chrome)..." -ForegroundColor Cyan +Write-Host "Port: $Port" +Write-Host "Renderer: $Renderer" +Write-Host "API: $currentApi" + +Push-Location (Join-Path $PSScriptRoot -ChildPath "sojorn_app") +try { + $cmdArgs = @( + 'run', + '-d', + 'chrome', + '--web-hostname', + 'localhost', + '--web-port', + "$Port" + ) + $cmdArgs += $defineArgs + + if ($NoWasmDryRun) { + $cmdArgs += '--no-wasm-dry-run' + } + + Write-Host "Running: flutter $($cmdArgs -join ' ')" -ForegroundColor DarkGray + & flutter $cmdArgs +} +finally { + Pop-Location +} diff --git a/run_windows.ps1 b/run_windows.ps1 new file mode 100644 index 0000000..0f2ebce --- /dev/null +++ b/run_windows.ps1 @@ -0,0 +1,111 @@ +param( + [string]$EnvPath = (Join-Path $PSScriptRoot ".env"), + [switch]$Release, + [switch]$Clean +) + +function Parse-Env($path) { + $vals = @{} + if (-not (Test-Path $path)) { + Write-Host "No .env file found at ${path}. Using defaults." -ForegroundColor Yellow + # Set default API_BASE_URL since no .env exists + $vals['API_BASE_URL'] = 'https://api.gosojorn.com/api/v1' + return $vals + } + Get-Content $path | ForEach-Object { + $line = $_.Trim() + if ($line.Length -eq 0 -or $line.StartsWith('#')) { return } + $parts = $line -split '=', 2 + if ($parts.Count -lt 2) { return } + $key = $parts[0].Trim() + $value = $parts[1].Trim() + if ($value.StartsWith('"') -and $value.EndsWith('"')) { + $value = $value.Substring(1, $value.Length - 2) + } + $vals[$key] = $value + } + return $vals +} + +$values = Parse-Env $EnvPath + +# Collect dart-defines we actually use on Windows. +$defineArgs = @() +$keysOfInterest = @('API_BASE_URL', 'FIREBASE_WEB_VAPID_KEY') +foreach ($k in $keysOfInterest) { + if ($values.ContainsKey($k) -and -not [string]::IsNullOrWhiteSpace($values[$k])) { + $defineArgs += "--dart-define=$k=$($values[$k])" + } +} + +# Ensure API_BASE_URL is set +if (-not $values.ContainsKey('API_BASE_URL') -or [string]::IsNullOrWhiteSpace($values['API_BASE_URL'])) { + $currentApi = 'https://api.gosojorn.com/api/v1' + $defineArgs += "--dart-define=API_BASE_URL=$currentApi" +} else { + $currentApi = $values['API_BASE_URL'] + if ($currentApi.StartsWith('http://localhost:')) { + # For local development, keep localhost but warn + Write-Host "Using local API: $currentApi" -ForegroundColor Yellow + } +} + +Write-Host "Building Sojorn for Windows..." -ForegroundColor Cyan +Write-Host "API: $currentApi" +Write-Host "Mode: $(if ($Release) { 'Release' } else { 'Debug' })" + +Push-Location (Join-Path $PSScriptRoot -ChildPath "sojorn_app") +try { + # Clean build if requested + if ($Clean) { + Write-Host "Cleaning build cache..." -ForegroundColor Yellow + & flutter clean + & flutter pub get + } + + # Build for Windows + $cmdArgs = @( + 'build', + 'windows' + ) + + if ($Release) { + $cmdArgs += '--release' + } else { + $cmdArgs += '--debug' + } + + $cmdArgs += $defineArgs + + Write-Host "Running: flutter $($cmdArgs -join ' ')" -ForegroundColor DarkGray + & flutter $cmdArgs + + if ($LASTEXITCODE -eq 0) { + $buildPath = Join-Path (Get-Location) "build\windows\x64\runner\Release" + if (-not $Release) { + $buildPath = Join-Path (Get-Location) "build\windows\x64\runner\Debug" + } + + $exePath = Join-Path $buildPath "sojorn_app.exe" + + if (Test-Path $exePath) { + Write-Host "Build successful!" -ForegroundColor Green + Write-Host "Executable: $exePath" -ForegroundColor Cyan + Write-Host "Build folder: $buildPath" -ForegroundColor Cyan + + # Ask if user wants to run the app + $response = Read-Host "Do you want to run the app now? (y/n)" + if ($response -eq 'y' -or $response -eq 'Y') { + Write-Host "Starting Sojorn Windows app..." -ForegroundColor Yellow + Start-Process -FilePath $exePath + } + } else { + Write-Host "Build completed but executable not found at: $exePath" -ForegroundColor Red + } + } else { + Write-Host "Build failed!" -ForegroundColor Red + } +} +finally { + Pop-Location +} diff --git a/run_windows_app.ps1 b/run_windows_app.ps1 new file mode 100644 index 0000000..62adf53 --- /dev/null +++ b/run_windows_app.ps1 @@ -0,0 +1,34 @@ +param( + [string]$BuildType = "Release", # Release or Debug + [switch]$BuildFirst +) + +$buildPath = Join-Path $PSScriptRoot "sojorn_app\build\windows\x64\runner\$BuildType" +$exePath = Join-Path $buildPath "sojorn_app.exe" + +Write-Host "Sojorn Windows App Runner" -ForegroundColor Cyan +Write-Host "Build Type: $BuildType" -ForegroundColor White +Write-Host "Executable: $exePath" -ForegroundColor White + +# Build first if requested +if ($BuildFirst) { + Write-Host "Building first..." -ForegroundColor Yellow + & "$PSScriptRoot\run_windows.ps1" -Release:($BuildType -eq "Release") + if ($LASTEXITCODE -ne 0) { + Write-Host "Build failed, cannot run app." -ForegroundColor Red + exit 1 + } +} + +# Check if executable exists +if (-not (Test-Path $exePath)) { + Write-Host "Executable not found: $exePath" -ForegroundColor Red + Write-Host "Try building first with: .\run_windows.ps1 -Release" -ForegroundColor Yellow + exit 1 +} + +# Run the app +Write-Host "Starting Sojorn Windows app..." -ForegroundColor Green +Start-Process -FilePath $exePath -WorkingDirectory $buildPath + +Write-Host "App launched! Check your taskbar or desktop for the Sojorn window." -ForegroundColor Cyan diff --git a/sojorn_app/assets/reactions/dotto/credit.md b/sojorn_app/assets/reactions/dotto/credit.md index e5314fd..86b71bc 100644 --- a/sojorn_app/assets/reactions/dotto/credit.md +++ b/sojorn_app/assets/reactions/dotto/credit.md @@ -1,11 +1,3 @@ # Dotto Emoji Set -**Source:** [Dotto Emoji](https://github.com/meritite-union/dotto-emoji) by meritite-union - -**Description:** 16x16 pixel art emoji set with a retro aesthetic - -**License:** Custom license - see repository for details - -**Format:** Scalable Vector Graphics (SVG) - -**Style:** Pixel art with clean, minimalist design +**Source:** [Dotto Emoji](https://github.com/meritite-union/dotto-emoji) by meritite-union \ No newline at end of file diff --git a/sojorn_app/assets/reactions/dotto/download_dotto_emoji.py b/sojorn_app/assets/reactions/dotto/download_dotto_emoji.py deleted file mode 100644 index a2c08bb..0000000 --- a/sojorn_app/assets/reactions/dotto/download_dotto_emoji.py +++ /dev/null @@ -1,93 +0,0 @@ -#!/usr/bin/env python3 -""" -Download Dotto Emoji SVG files from GitHub repository -""" - -import requests -import os -from urllib.parse import urlparse - -# Mapping of emoji names to GitHub SVG files -EMOJI_MAPPING = { - 'heart.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji0.svg', - 'thumbs_up.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji1.svg', - 'laughing_face.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/face_with_tears_of_joy.svg', - 'face_with_open_mouth.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji2.svg', - 'sad_face.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji3.svg', - 'angry_face.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji4.svg', - 'party_popper.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji5.svg', - 'fire.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji6.svg', - 'clapping_hands.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji7.svg', - 'folded_hands.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji8.svg', - 'hundred_points.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji9.svg', - 'thinking_face.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji10.svg', - 'beaming_face.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/beaming_face_with_smiling_eyes.svg', - 'face_with_tears.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/face_with_tears_of_joy.svg', - 'grinning_face.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/grinning_face.svg', - 'smiling_face.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/smiling_face_with_smiling_eyes.svg', - 'winking_face.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/winking_face.svg', - 'melting_face.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/melting_face.svg', - 'upside_down_face.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/upside-down_face.svg', - 'rolling_face.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/rolling_on_the_floor_laughing.svg', - 'slightly_smiling_face.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/slightly_smiling_face.svg', - 'smiling_face_with_halo.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/smiling_face_with_halo.svg', - 'smiling_face_with_hearts.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/smiling_face_with_hearts.svg', - 'face_with_monocle.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji65.svg', - 'nerd_face.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji66.svg', - 'party_face.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji67.svg', - 'sunglasses_face.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji68.svg', - 'disappointed_face.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji69.svg', - 'worried_face.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji70.svg', - 'anguished_face.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji71.svg', - 'fearful_face.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji72.svg', - 'downcast_face.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji73.svg', - 'loudly_crying_face.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji74.svg', - 'skull.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji75.svg', - 'ghost.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji76.svg', - 'robot_face.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji77.svg', - 'heart_with_arrow.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji78.svg', - 'broken_heart.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji79.svg', - 'sparkling_heart.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji80.svg', - 'green_heart.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji81.svg', - 'blue_heart.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji82.svg', - 'purple_heart.svg': 'https://raw.githubusercontent.com/meritite-union/dotto-emoji/main/icons/svg/Dotto%20Emoji83.svg', -} - -def download_file(url, local_path): - """Download a file from URL to local path""" - try: - response = requests.get(url, timeout=10) - response.raise_for_status() - - with open(local_path, 'wb') as f: - f.write(response.content) - - print(f"✓ Downloaded {os.path.basename(local_path)}") - return True - except Exception as e: - print(f"✗ Failed to download {os.path.basename(local_path)}: {e}") - return False - -def main(): - """Main function to download all emoji files""" - print("Downloading Dotto Emoji SVG files...") - - # Create directory if it doesn't exist - os.makedirs('.', exist_ok=True) - - success_count = 0 - total_count = len(EMOJI_MAPPING) - - for filename, url in EMOJI_MAPPING.items(): - local_path = filename - if download_file(url, local_path): - success_count += 1 - - print(f"\nDownload complete: {success_count}/{total_count} files downloaded") - - if success_count > 0: - print("\nThe Dotto emoji set is now ready to use!") - print("You can now run 'flutter pub get' to install dependencies.") - -if __name__ == "__main__": - main() diff --git a/sojorn_app/lib/models/post.dart b/sojorn_app/lib/models/post.dart index a0e46ae..d3c4e76 100644 --- a/sojorn_app/lib/models/post.dart +++ b/sojorn_app/lib/models/post.dart @@ -330,12 +330,16 @@ class PostPreview { final String body; final DateTime createdAt; final Profile? author; + final Map? reactions; + final List? myReactions; const PostPreview({ required this.id, required this.body, required this.createdAt, this.author, + this.reactions, + this.myReactions, }); factory PostPreview.fromJson(Map json) { @@ -345,6 +349,8 @@ class PostPreview { body: json['body'] as String? ?? '', createdAt: DateTime.parse(json['created_at'] as String), author: authorJson != null ? Profile.fromJson(authorJson) : null, + reactions: Post._parseReactions(json['reactions'] ?? json['reaction_counts']), + myReactions: Post._parseReactionsList(json['my_reactions'] ?? json['myReactions']), ); } @@ -354,6 +360,8 @@ class PostPreview { body: post.body, createdAt: post.createdAt, author: post.author, + reactions: post.reactions, + myReactions: post.myReactions, ); } @@ -363,6 +371,8 @@ class PostPreview { 'body': body, 'created_at': createdAt.toIso8601String(), 'author': author?.toJson(), + 'reactions': reactions, + 'my_reactions': myReactions, }; } } diff --git a/sojorn_app/lib/screens/auth/sign_up_screen.dart b/sojorn_app/lib/screens/auth/sign_up_screen.dart index 18ee85d..885476f 100644 --- a/sojorn_app/lib/screens/auth/sign_up_screen.dart +++ b/sojorn_app/lib/screens/auth/sign_up_screen.dart @@ -251,7 +251,7 @@ class _SignUpScreenState extends ConsumerState { // Terms and privacy note Text( - 'By continuing, you agree to our vibrant community guidelines', // Updated text + 'By continuing, you agree to our vibrant community guidelines.\nA product of MPLS LLC.', // Updated text style: AppTheme.textTheme.labelSmall?.copyWith( // Replaced AppTheme.bodySmall color: AppTheme diff --git a/sojorn_app/lib/screens/home/home_shell.dart b/sojorn_app/lib/screens/home/home_shell.dart index 2a157b5..dea3637 100644 --- a/sojorn_app/lib/screens/home/home_shell.dart +++ b/sojorn_app/lib/screens/home/home_shell.dart @@ -169,14 +169,10 @@ class _HomeShellState extends State with WidgetsBindingObserver { PreferredSizeWidget _buildAppBar() { return AppBar( - title: Text( - 'sojorn', - style: GoogleFonts.literata( - fontSize: 28, - fontWeight: FontWeight.w700, - letterSpacing: -0.5, - color: AppTheme.navyBlue, - ), + title: Image.asset( + 'assets/images/toplogo.png', + height: 38, + fit: BoxFit.contain, ), centerTitle: false, elevation: 0, diff --git a/sojorn_app/lib/screens/post/threaded_conversation_screen.dart b/sojorn_app/lib/screens/post/threaded_conversation_screen.dart index dd3d2ef..90678a1 100644 --- a/sojorn_app/lib/screens/post/threaded_conversation_screen.dart +++ b/sojorn_app/lib/screens/post/threaded_conversation_screen.dart @@ -6,7 +6,7 @@ import 'package:flutter_animate/flutter_animate.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:timeago/timeago.dart' as timeago; import '../../widgets/reactions/reaction_picker.dart'; -import '../../widgets/reactions/reaction_strip.dart'; +import '../../widgets/reactions/reactions_display.dart'; import '../../models/post.dart'; import '../../providers/api_provider.dart'; import '../../theme/app_theme.dart'; @@ -387,6 +387,15 @@ class _ThreadedConversationScreenState extends ConsumerState _toggleReaction(parentPost.id, emoji), + onAddReaction: () => _openReactionPicker(parentPost.id), + mode: ReactionsDisplayMode.compact, + padding: EdgeInsets.zero, + ), ], ), ), @@ -552,12 +561,13 @@ class _ThreadedConversationScreenState extends ConsumerState _toggleReaction(post.id, emoji), - onAdd: () => _openReactionPicker(post.id), + onToggleReaction: (emoji) => _toggleReaction(post.id, emoji), + onAddReaction: () => _openReactionPicker(post.id), + mode: ReactionsDisplayMode.full, ), const SizedBox(height: 16), // Actions row - left aligned @@ -869,21 +879,20 @@ class _ThreadedConversationScreenState extends ConsumerState a + b)}', - style: GoogleFonts.inter( - color: AppTheme.textSecondary, - fontSize: 10, + Expanded( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + physics: const NeverScrollableScrollPhysics(), + child: ReactionsDisplay( + reactionCounts: _reactionCountsFor(post), + myReactions: _myReactionsFor(post), + onToggleReaction: (emoji) => _toggleReaction(post.id, emoji), + onAddReaction: () => _openReactionPicker(post.id), + mode: ReactionsDisplayMode.compact, + padding: EdgeInsets.zero, ), ), - ], + ), const Spacer(), Container( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), diff --git a/sojorn_app/lib/screens/profile/profile_screen.dart b/sojorn_app/lib/screens/profile/profile_screen.dart index 4f792b6..a737f11 100644 --- a/sojorn_app/lib/screens/profile/profile_screen.dart +++ b/sojorn_app/lib/screens/profile/profile_screen.dart @@ -16,7 +16,7 @@ import '../compose/compose_screen.dart'; import '../post/post_detail_screen.dart'; import 'profile_settings_screen.dart'; -enum ProfileFeedType { posts, appreciated, saved, chained } +enum ProfileFeedType { posts, saved, chained } /// Premium profile screen with NestedScrollView and SliverAppBar class ProfileScreen extends ConsumerStatefulWidget { @@ -55,11 +55,7 @@ class _ProfileScreenState extends ConsumerState bool _hasMorePosts = true; String? _postsError; - List _appreciatedPosts = []; - bool _isAppreciatedLoading = false; - bool _isAppreciatedLoadingMore = false; - bool _hasMoreAppreciated = true; - String? _appreciatedError; + List _savedPosts = []; bool _isSavedLoading = false; @@ -76,7 +72,7 @@ class _ProfileScreenState extends ConsumerState @override void initState() { super.initState(); - _tabController = TabController(length: 4, vsync: this); + _tabController = TabController(length: 3, vsync: this); _tabController.addListener(() { if (!_tabController.indexIsChanging) { setState(() { @@ -231,8 +227,6 @@ class _ProfileScreenState extends ConsumerState switch (_activeFeed) { case ProfileFeedType.posts: return _loadPosts(refresh: refresh); - case ProfileFeedType.appreciated: - return _loadAppreciated(refresh: refresh); case ProfileFeedType.saved: return _loadSaved(refresh: refresh); case ProfileFeedType.chained: @@ -297,62 +291,7 @@ class _ProfileScreenState extends ConsumerState } } - Future _loadAppreciated({bool refresh = false}) async { - if (_profile == null) return; - if (refresh) { - setState(() { - _appreciatedPosts = []; - _hasMoreAppreciated = true; - _appreciatedError = null; - }); - } else if (!_hasMoreAppreciated || _isAppreciatedLoadingMore) { - return; - } - - setState(() { - if (refresh) { - _isAppreciatedLoading = true; - } else { - _isAppreciatedLoadingMore = true; - } - if (!refresh) { - _appreciatedError = null; - } - }); - - try { - final apiService = ref.read(apiServiceProvider); - final posts = await apiService.getAppreciatedPosts( - userId: _profile!.id, - limit: _postsPageSize, - offset: refresh ? 0 : _appreciatedPosts.length, - ); - - if (!mounted) return; - - setState(() { - if (refresh) { - _appreciatedPosts = posts; - } else { - _appreciatedPosts.addAll(posts); - } - _hasMoreAppreciated = posts.length == _postsPageSize; - }); - } catch (error) { - if (!mounted) return; - setState(() { - _appreciatedError = error.toString().replaceAll('Exception: ', ''); - }); - } finally { - if (mounted) { - setState(() { - _isAppreciatedLoading = false; - _isAppreciatedLoadingMore = false; - }); - } - } - } Future _loadSaved({bool refresh = false}) async { if (_profile == null) return; @@ -754,8 +693,6 @@ class _ProfileScreenState extends ConsumerState switch (type) { case ProfileFeedType.posts: return _posts; - case ProfileFeedType.appreciated: - return _appreciatedPosts; case ProfileFeedType.saved: return _savedPosts; case ProfileFeedType.chained: @@ -767,8 +704,6 @@ class _ProfileScreenState extends ConsumerState switch (type) { case ProfileFeedType.posts: return _isPostsLoading; - case ProfileFeedType.appreciated: - return _isAppreciatedLoading; case ProfileFeedType.saved: return _isSavedLoading; case ProfileFeedType.chained: @@ -780,8 +715,6 @@ class _ProfileScreenState extends ConsumerState switch (type) { case ProfileFeedType.posts: return _isPostsLoadingMore; - case ProfileFeedType.appreciated: - return _isAppreciatedLoadingMore; case ProfileFeedType.saved: return _isSavedLoadingMore; case ProfileFeedType.chained: @@ -793,8 +726,6 @@ class _ProfileScreenState extends ConsumerState switch (type) { case ProfileFeedType.posts: return _hasMorePosts; - case ProfileFeedType.appreciated: - return _hasMoreAppreciated; case ProfileFeedType.saved: return _hasMoreSaved; case ProfileFeedType.chained: @@ -806,8 +737,6 @@ class _ProfileScreenState extends ConsumerState switch (type) { case ProfileFeedType.posts: return _postsError; - case ProfileFeedType.appreciated: - return _appreciatedError; case ProfileFeedType.saved: return _savedError; case ProfileFeedType.chained: @@ -815,6 +744,7 @@ class _ProfileScreenState extends ConsumerState } } + @override Widget build(BuildContext context) { final profile = _profile; @@ -910,7 +840,6 @@ class _ProfileScreenState extends ConsumerState labelStyle: AppTheme.labelMedium, tabs: const [ Tab(text: 'Posts'), - Tab(text: 'Appreciated'), Tab(text: 'Saved'), Tab(text: 'Chains'), ], diff --git a/sojorn_app/lib/screens/profile/profile_settings_screen.dart b/sojorn_app/lib/screens/profile/profile_settings_screen.dart index dc17e76..653e9cc 100644 --- a/sojorn_app/lib/screens/profile/profile_settings_screen.dart +++ b/sojorn_app/lib/screens/profile/profile_settings_screen.dart @@ -335,7 +335,6 @@ class _ProfileSettingsScreenState extends ConsumerState { ); }, ), - const SizedBox(height: AppTheme.spacingLg), Align( alignment: Alignment.centerRight, child: TextButton( @@ -346,6 +345,34 @@ class _ProfileSettingsScreenState extends ConsumerState { width: 20, child: CircularProgressIndicator(strokeWidth: 2)) : const Text('Save'))), + const SizedBox(height: AppTheme.spacingLg * 2), // Fixed spacing2xl error + Center( + child: Column( + children: [ + Text( + 'Sojorn', + style: AppTheme.textTheme.labelMedium?.copyWith( + color: AppTheme.navyText.withOpacity(0.5), + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 4), + Text( + 'A product of MPLS LLC', + style: AppTheme.textTheme.labelSmall?.copyWith( + color: AppTheme.navyText.withOpacity(0.4), + ), + ), + Text( + '© ${DateTime.now().year} All rights reserved', + style: AppTheme.textTheme.labelSmall?.copyWith( + color: AppTheme.navyText.withOpacity(0.4), + ), + ), + ], + ), + ), + const SizedBox(height: AppTheme.spacingLg), ]), ), ); diff --git a/sojorn_app/lib/screens/profile/viewable_profile_screen.dart b/sojorn_app/lib/screens/profile/viewable_profile_screen.dart index 63a1853..809c181 100644 --- a/sojorn_app/lib/screens/profile/viewable_profile_screen.dart +++ b/sojorn_app/lib/screens/profile/viewable_profile_screen.dart @@ -64,15 +64,28 @@ class _ViewableProfileScreenState extends ConsumerState bool _hasMorePosts = true; String? _postsError; + List _savedPosts = []; + bool _isSavedLoading = false; + bool _isSavedLoadingMore = false; + bool _hasMoreSaved = true; + String? _savedError; + + List _chainedPosts = []; + bool _isChainedLoading = false; + bool _isChainedLoadingMore = false; + bool _hasMoreChained = true; + String? _chainedError; + @override void initState() { super.initState(); - _tabController = TabController(length: 2, vsync: this); + _tabController = TabController(length: 4, vsync: this); _tabController.addListener(() { if (!_tabController.indexIsChanging) { setState(() { _activeTab = _tabController.index; }); + _loadActiveFeed(); } }); @@ -136,6 +149,20 @@ class _ViewableProfileScreenState extends ConsumerState } } + Future _loadActiveFeed() async { + switch (_activeTab) { + case 0: + if (_posts.isEmpty) _loadPosts(refresh: true); + break; + case 1: + if (_savedPosts.isEmpty) _loadSaved(refresh: true); + break; + case 2: + if (_chainedPosts.isEmpty) _loadChained(refresh: true); + break; + } + } + Future _loadPosts({bool refresh = false}) async { if (_profile == null) return; @@ -190,6 +217,121 @@ class _ViewableProfileScreenState extends ConsumerState } } + Future _loadSaved({bool refresh = false}) async { + if (_profile == null) return; + + if (refresh) { + setState(() { + _savedPosts = []; + _hasMoreSaved = true; + _savedError = null; + }); + } else if (!_hasMoreSaved || _isSavedLoadingMore) { + return; + } + + setState(() { + if (refresh) { + _isSavedLoading = true; + } else { + _isSavedLoadingMore = true; + } + if (!refresh) { + _savedError = null; + } + }); + + try { + final apiService = ref.read(apiServiceProvider); + // NOTE: This will only return posts if the backend/permission allows. + final posts = await apiService.getSavedPosts( + userId: _profile!.id, + limit: _postsPageSize, + offset: refresh ? 0 : _savedPosts.length, + ); + + if (!mounted) return; + + setState(() { + if (refresh) { + _savedPosts = posts; + } else { + _savedPosts.addAll(posts); + } + _hasMoreSaved = posts.length == _postsPageSize; + }); + } catch (error) { + if (!mounted) return; + setState(() { + _savedError = error.toString().replaceAll('Exception: ', ''); + }); + } finally { + if (mounted) { + setState(() { + _isSavedLoading = false; + _isSavedLoadingMore = false; + }); + } + } + } + + Future _loadChained({bool refresh = false}) async { + if (_profile == null) return; + + if (refresh) { + setState(() { + _chainedPosts = []; + _hasMoreChained = true; + _chainedError = null; + }); + } else if (!_hasMoreChained || _isChainedLoadingMore) { + return; + } + + setState(() { + if (refresh) { + _isChainedLoading = true; + } else { + _isChainedLoadingMore = true; + } + if (!refresh) { + _chainedError = null; + } + }); + + try { + final apiService = ref.read(apiServiceProvider); + final posts = await apiService.getChainedPostsForAuthor( + authorId: _profile!.id, + limit: _postsPageSize, + offset: refresh ? 0 : _chainedPosts.length, + ); + + if (!mounted) return; + + setState(() { + if (refresh) { + _chainedPosts = posts; + } else { + _chainedPosts.addAll(posts); + } + _hasMoreChained = posts.length == _postsPageSize; + }); + } catch (error) { + if (!mounted) return; + setState(() { + _chainedError = error.toString().replaceAll('Exception: ', ''); + }); + } finally { + if (mounted) { + setState(() { + _isChainedLoading = false; + _isChainedLoadingMore = false; + }); + } + } + } + Future _loadPrivacySettings() async { if (_isPrivacyLoading) return; @@ -616,6 +758,8 @@ class _ViewableProfileScreenState extends ConsumerState labelStyle: AppTheme.labelMedium, tabs: const [ Tab(text: 'Posts'), + Tab(text: 'Saved'), + Tab(text: 'Chains'), Tab(text: 'About'), ], ), @@ -624,33 +768,77 @@ class _ViewableProfileScreenState extends ConsumerState } Widget _buildTabBarView() { - if (_activeTab == 1) { + if (_activeTab == 3) { return _buildAboutTab(); } + if (_activeTab == 1) { + return _buildFeedView( + _savedPosts, + _isSavedLoading, + _isSavedLoadingMore, + _hasMoreSaved, + _savedError, + () => _loadSaved(refresh: true), + () => _loadSaved(refresh: false), + ); + } + + if (_activeTab == 2) { + return _buildFeedView( + _chainedPosts, + _isChainedLoading, + _isChainedLoadingMore, + _hasMoreChained, + _chainedError, + () => _loadChained(refresh: true), + () => _loadChained(refresh: false), + ); + } + + return _buildFeedView( + _posts, + _isPostsLoading, + _isPostsLoadingMore, + _hasMorePosts, + _postsError, + () => _loadPosts(refresh: true), + () => _loadPosts(refresh: false), + ); + } + + Widget _buildFeedView( + List posts, + bool isLoading, + bool isLoadingMore, + bool hasMore, + String? error, + VoidCallback onRefresh, + VoidCallback onLoadMore, + ) { return RefreshIndicator( - onRefresh: () => _loadPosts(refresh: true), + onRefresh: () async => onRefresh(), child: CustomScrollView( slivers: [ - if (_postsError != null) + if (error != null) SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.all(AppTheme.spacingLg), child: Text( - _postsError!, + error, style: AppTheme.bodyMedium.copyWith(color: AppTheme.error), textAlign: TextAlign.center, ), ), ), - if (_isPostsLoading && _posts.isEmpty) + if (isLoading && posts.isEmpty) const SliverToBoxAdapter( child: Padding( padding: EdgeInsets.symmetric(vertical: AppTheme.spacingLg), child: Center(child: CircularProgressIndicator()), ), ), - if (_posts.isEmpty && !_isPostsLoading) + if (posts.isEmpty && !isLoading) SliverFillRemaining( child: Center( child: Text( @@ -661,7 +849,7 @@ class _ViewableProfileScreenState extends ConsumerState ), ), ), - if (_posts.isNotEmpty) + if (posts.isNotEmpty) SliverPadding( padding: const EdgeInsets.symmetric( horizontal: AppTheme.spacingMd, @@ -670,10 +858,10 @@ class _ViewableProfileScreenState extends ConsumerState sliver: SliverList( delegate: SliverChildBuilderDelegate( (context, index) { - final post = _posts[index]; + final post = posts[index]; return Padding( padding: EdgeInsets.only( - bottom: index == _posts.length - 1 + bottom: index == posts.length - 1 ? 0 : AppTheme.spacingSm, ), @@ -684,22 +872,22 @@ class _ViewableProfileScreenState extends ConsumerState ), ); }, - childCount: _posts.length, + childCount: posts.length, ), ), ), - if (_isPostsLoadingMore) + if (isLoadingMore) const SliverToBoxAdapter( child: Padding( padding: EdgeInsets.symmetric(vertical: AppTheme.spacingLg), child: Center(child: CircularProgressIndicator()), ), ), - if (!_isPostsLoadingMore && _hasMorePosts && _posts.isNotEmpty) + if (!isLoadingMore && hasMore && posts.isNotEmpty) SliverToBoxAdapter( child: Center( child: TextButton( - onPressed: () => _loadPosts(refresh: false), + onPressed: onLoadMore, child: const Text('Load more'), ), ), diff --git a/sojorn_app/lib/screens/secure_chat/secure_chat_full_screen.dart b/sojorn_app/lib/screens/secure_chat/secure_chat_full_screen.dart index dd3c447..d58be65 100644 --- a/sojorn_app/lib/screens/secure_chat/secure_chat_full_screen.dart +++ b/sojorn_app/lib/screens/secure_chat/secure_chat_full_screen.dart @@ -425,6 +425,7 @@ class _SecureChatFullScreenState extends State { ), ); }, + onDelete: () => _confirmDeleteConversation(conversation), ); }, ), @@ -432,133 +433,247 @@ class _SecureChatFullScreenState extends State { }, ); } + + Future _confirmDeleteConversation(SecureConversation conversation) async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + backgroundColor: AppTheme.cardSurface, + title: Text( + 'Delete Conversation?', + style: GoogleFonts.literata( + fontWeight: FontWeight.w600, + color: AppTheme.navyBlue, + ), + ), + content: Text( + 'Are you sure you want to delete this conversation with ${conversation.otherUserDisplayName ?? conversation.otherUserHandle}? This will remove all messages for everyone.', + style: GoogleFonts.inter(color: AppTheme.navyText), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: Text( + 'Cancel', + style: GoogleFonts.inter(color: AppTheme.egyptianBlue), + ), + ), + ElevatedButton( + onPressed: () => Navigator.pop(context, true), + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.error, + foregroundColor: Colors.white, + ), + child: Text('Delete'), + ), + ], + ), + ); + + if (confirmed == true) { + try { + await _chatService.deleteConversation(conversation.id, fullDelete: true); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Conversation deleted'), + backgroundColor: Colors.green, + ), + ); + _loadConversations(); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to delete: $e'), + backgroundColor: Colors.red, + ), + ); + } + } + } + } } -class _ConversationTile extends StatelessWidget { +class _ConversationTile extends StatefulWidget { final SecureConversation conversation; final VoidCallback onTap; + final VoidCallback onDelete; const _ConversationTile({ required this.conversation, required this.onTap, + required this.onDelete, }); + @override + State<_ConversationTile> createState() => _ConversationTileState(); +} + +class _ConversationTileState extends State<_ConversationTile> { + bool _isHovered = false; + @override Widget build(BuildContext context) { - return Container( - margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), - decoration: BoxDecoration( - color: AppTheme.cardSurface, - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: AppTheme.navyBlue.withValues(alpha: 0.1), - width: 1, + return MouseRegion( + onEnter: (_) => setState(() => _isHovered = true), + onExit: (_) => setState(() => _isHovered = false), + child: Dismissible( + key: Key('conv_${widget.conversation.id}'), + direction: DismissDirection.endToStart, + confirmDismiss: (direction) async { + widget.onDelete(); + return false; // Let the full screen state handle the actual removal + }, + background: Container( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + decoration: BoxDecoration( + color: AppTheme.error, + borderRadius: BorderRadius.circular(12), + ), + alignment: Alignment.centerRight, + padding: const EdgeInsets.only(right: 24), + child: const Icon( + Icons.delete_outline, + color: Colors.white, + size: 28, + ), ), - ), - child: Material( - color: Colors.transparent, - child: InkWell( - borderRadius: BorderRadius.circular(12), - onTap: onTap, - child: Padding( - padding: const EdgeInsets.all(16), - child: Row( - children: [ - // Avatar - Container( - width: 56, - height: 56, - decoration: BoxDecoration( - color: AppTheme.brightNavy.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(28), - ), - child: conversation.otherUserAvatarUrl != null - ? ClipRRect( - borderRadius: BorderRadius.circular(28), - child: SignedMediaImage( - url: conversation.otherUserAvatarUrl!, - width: 56, - height: 56, - fit: BoxFit.cover, - ), - ) - : Center( - child: Text( - (conversation.otherUserDisplayName ?? - '@${conversation.otherUserHandle ?? 'Unknown'}') - .isNotEmpty - ? (conversation.otherUserDisplayName ?? - '@${conversation.otherUserHandle ?? 'Unknown'}')[0] - .toUpperCase() - : '?', - style: GoogleFonts.inter( - color: AppTheme.navyBlue, - fontWeight: FontWeight.bold, - fontSize: 20, + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + decoration: BoxDecoration( + color: AppTheme.cardSurface, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: AppTheme.navyBlue.withValues(alpha: 0.1), + width: 1, + ), + ), + child: Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(12), + onTap: widget.onTap, + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + // Avatar + Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: AppTheme.brightNavy.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(28), + ), + child: widget.conversation.otherUserAvatarUrl != null + ? ClipRRect( + borderRadius: BorderRadius.circular(28), + child: SignedMediaImage( + url: widget.conversation.otherUserAvatarUrl!, + width: 56, + height: 56, + fit: BoxFit.cover, + ), + ) + : Center( + child: Text( + (widget.conversation.otherUserDisplayName ?? + '@${widget.conversation.otherUserHandle ?? 'Unknown'}') + .isNotEmpty + ? (widget.conversation.otherUserDisplayName ?? + '@${widget.conversation.otherUserHandle ?? 'Unknown'}')[0] + .toUpperCase() + : '?', + style: GoogleFonts.inter( + color: AppTheme.navyBlue, + fontWeight: FontWeight.bold, + fontSize: 20, + ), + ), ), + ), + const SizedBox(width: 16), + // Content + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + widget.conversation.otherUserDisplayName ?? + '@${widget.conversation.otherUserHandle ?? 'Unknown'}', + style: GoogleFonts.literata( + fontWeight: FontWeight.w600, + color: AppTheme.navyBlue, + fontSize: 16, + ), + overflow: TextOverflow.ellipsis, + ), + ), + if (widget.conversation.lastMessageAt != null) + Text( + timeago.format(widget.conversation.lastMessageAt!), + style: GoogleFonts.inter( + color: AppTheme.textDisabled, + fontSize: 12, + ), + ), + ], + ), + const SizedBox(height: 4), + Row( + children: [ + Icon( + Icons.lock, + size: 12, + color: AppTheme.brightNavy.withValues(alpha: 0.5), + ), + const SizedBox(width: 4), + Expanded( + child: Text( + widget.conversation.lastMessageAt != null + ? 'Recent message' + : 'Start a conversation', + style: GoogleFonts.inter( + color: widget.conversation.lastMessageAt != null + ? AppTheme.textSecondary + : AppTheme.textDisabled, + fontSize: 14, + ), + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), + ), + ], + ), + ], + ), + ), + // Hover delete button for web/desktop + if (_isHovered) + Padding( + padding: const EdgeInsets.only(left: 8), + child: IconButton( + onPressed: widget.onDelete, + icon: const Icon( + Icons.delete_outline, + color: AppTheme.error, + size: 20, + ), + tooltip: 'Delete conversation', + style: IconButton.styleFrom( + backgroundColor: AppTheme.error.withValues(alpha: 0.1), + padding: const EdgeInsets.all(8), ), ), - ), - const SizedBox(width: 16), - // Content - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded( - child: Text( - conversation.otherUserDisplayName ?? - '@${conversation.otherUserHandle ?? 'Unknown'}', - style: GoogleFonts.literata( - fontWeight: FontWeight.w600, - color: AppTheme.navyBlue, - fontSize: 16, - ), - overflow: TextOverflow.ellipsis, - ), - ), - if (conversation.lastMessageAt != null) - Text( - timeago.format(conversation.lastMessageAt!), - style: GoogleFonts.inter( - color: AppTheme.textDisabled, - fontSize: 12, - ), - ), - ], ), - const SizedBox(height: 4), - Row( - children: [ - Icon( - Icons.lock, - size: 12, - color: AppTheme.brightNavy.withValues(alpha: 0.5), - ), - const SizedBox(width: 4), - Expanded( - child: Text( - conversation.lastMessageAt != null - ? 'Recent message' - : 'Start a conversation', - style: GoogleFonts.inter( - color: conversation.lastMessageAt != null - ? AppTheme.textSecondary - : AppTheme.textDisabled, - fontSize: 14, - ), - overflow: TextOverflow.ellipsis, - maxLines: 1, - ), - ), - ], - ), - ], - ), + ], ), - ], + ), ), ), ), diff --git a/sojorn_app/lib/screens/secure_chat/secure_chat_screen.dart b/sojorn_app/lib/screens/secure_chat/secure_chat_screen.dart index ca8d649..b304dab 100644 --- a/sojorn_app/lib/screens/secure_chat/secure_chat_screen.dart +++ b/sojorn_app/lib/screens/secure_chat/secure_chat_screen.dart @@ -360,35 +360,61 @@ class _SecureChatScreenState extends State label: dateLabel, ), ), - ChatBubbleWidget( - key: ValueKey('${current.id}-${current.isPending}'), - message: current.text, - isMe: current.isMe, - timestamp: current.timestamp, - isSending: current.isPending && !current.sendFailed, - sendFailed: current.sendFailed, - isDelivered: current.isDelivered, - isRead: current.isRead, - decryptionFailed: current.decryptionFailed, - isFirstInCluster: startsCluster, - isLastInCluster: endsCluster, - showAvatar: true, - avatarUrl: current.isMe - ? _currentUserAvatarUrl - : _otherUserAvatarUrl, - avatarInitial: current.isMe - ? _currentUserInitial - : _otherUserInitial, - onLongPress: current.isPending - ? null - : () => _showMessageOptions(current), - onDelete: current.isPending - ? () => _removePending(current.id) - : () => _confirmDeleteMessage( - current.id, - forEveryone: false, - ), - onReply: () => _startReply(current), + Dismissible( + key: ValueKey('swipe-${current.id}-${current.isPending}'), + direction: DismissDirection.endToStart, + confirmDismiss: (direction) async { + if (current.isPending) { + _removePending(current.id); + } else { + _confirmDeleteMessage(current.id, forEveryone: false); + } + return false; // Let confirmation dialog handle it + }, + background: Container( + margin: const EdgeInsets.symmetric(vertical: 4), + decoration: BoxDecoration( + color: AppTheme.error.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), + ), + alignment: Alignment.centerRight, + padding: const EdgeInsets.only(right: 24), + child: Icon( + Icons.delete_outline, + color: AppTheme.error, + size: 24, + ), + ), + child: ChatBubbleWidget( + key: ValueKey('${current.id}-${current.isPending}'), + message: current.text, + isMe: current.isMe, + timestamp: current.timestamp, + isSending: current.isPending && !current.sendFailed, + sendFailed: current.sendFailed, + isDelivered: current.isDelivered, + isRead: current.isRead, + decryptionFailed: current.decryptionFailed, + isFirstInCluster: startsCluster, + isLastInCluster: endsCluster, + showAvatar: true, + avatarUrl: current.isMe + ? _currentUserAvatarUrl + : _otherUserAvatarUrl, + avatarInitial: current.isMe + ? _currentUserInitial + : _otherUserInitial, + onLongPress: current.isPending + ? null + : () => _showMessageOptions(current), + onDelete: current.isPending + ? () => _removePending(current.id) + : () => _confirmDeleteMessage( + current.id, + forEveryone: false, + ), + onReply: () => _startReply(current), + ), ), ], ); diff --git a/sojorn_app/lib/services/api_service.dart b/sojorn_app/lib/services/api_service.dart index 8bc3b7d..4c6a14a 100644 --- a/sojorn_app/lib/services/api_service.dart +++ b/sojorn_app/lib/services/api_service.dart @@ -17,13 +17,12 @@ import '../models/tone_analysis.dart'; import '../utils/security_utils.dart'; import '../utils/request_signing.dart'; import 'package:http/http.dart' as http; - /// ApiService - Single source of truth for all backend communication. class ApiService { final AuthService _authService; - final http.Client _httpClient = http.Client(); + final http.Client _httpClient; - ApiService(this._authService); + ApiService(this._authService) : _httpClient = http.Client(); // Singleton pattern helper if needed, but usually passed via DI/Riverpod static ApiService? _instance; @@ -146,9 +145,6 @@ class ApiService { return { 'Authorization': 'Bearer $token', - 'X-Rate-Limit-Remaining': '100', - 'X-Rate-Limit-Reset': '3600', - 'X-Request-ID': _generateRequestId(), }; } @@ -428,6 +424,7 @@ class ApiService { required String authorId, int limit = 20, int offset = 0, + bool onlyChains = false, }) async { final data = await _callGoApi( '/users/$authorId/posts', @@ -435,6 +432,7 @@ class ApiService { queryParams: { 'limit': limit.toString(), 'offset': offset.toString(), + if (onlyChains) 'chained': 'true', }, ); @@ -477,7 +475,7 @@ class ApiService { int offset = 0, }) async { final data = await _callGoApi( - '/users/me/saved', + '/users/$userId/saved', method: 'GET', queryParams: {'limit': '$limit', 'offset': '$offset'}, ); @@ -490,7 +488,12 @@ class ApiService { int limit = 20, int offset = 0, }) async { - return []; // Go API doesn't have a direct equivalent for 'get chained posts' yet, or use /feed?author_id=...&chained=true + return getProfilePosts( + authorId: authorId, + limit: limit, + offset: offset, + onlyChains: true, + ); } Future> getChainPosts({ @@ -1091,4 +1094,92 @@ class ApiService { method: 'DELETE', ); } + + // ========================================================================= + // Key Backup & Recovery + // ========================================================================= + + /// Upload an encrypted backup blob to cloud storage + /// [encryptedBlob] - The base64 encoded encrypted backup data + /// [salt] - The base64 encoded salt used for key derivation + /// [nonce] - The base64 encoded nonce used for encryption + /// [mac] - The base64 encoded auth tag + Future> uploadBackup({ + required String encryptedBlob, + required String salt, + required String nonce, + required String mac, + required String deviceName, + int version = 1, + }) async { + return await _callGoApi( + '/backup/upload', + method: 'POST', + body: { + 'encrypted_blob': encryptedBlob, + 'salt': salt, + 'nonce': nonce, + 'mac': mac, + 'device_name': deviceName, + 'version': version, + }, + ); + } + + /// Download the latest backup from cloud storage + Future?> downloadBackup([String? backupId]) async { + try { + final path = backupId != null ? '/backup/download/$backupId' : '/backup/download'; + final data = await _callGoApi(path, method: 'GET'); + return data; + } catch (e) { + if (e.toString().contains('404')) { + return null; + } + rethrow; + } + } + + /// List all backups + Future>> listBackups() async { + final data = await _callGoApi('/backup/list', method: 'GET'); + return (data['backups'] as List).cast>(); + } + + /// Delete a backup + Future deleteBackup(String backupId) async { + await _callGoApi('/backup/$backupId', method: 'DELETE'); + } + + /// Get sync code for device pairing + Future> generateSyncCode({ + required String deviceName, + required String deviceFingerprint, + }) async { + return await _callGoApi( + '/backup/sync/generate-code', + method: 'POST', + body: { + 'device_name': deviceName, + 'device_fingerprint': deviceFingerprint, + }, + ); + } + + /// Verify sync code + Future> verifySyncCode({ + required String code, + required String deviceName, + required String deviceFingerprint, + }) async { + return await _callGoApi( + '/backup/sync/verify-code', + method: 'POST', + body: { + 'code': code, + 'device_name': deviceName, + 'device_fingerprint': deviceFingerprint, + }, + ); + } } diff --git a/sojorn_app/lib/services/local_key_backup_service.dart b/sojorn_app/lib/services/local_key_backup_service.dart index fa7998b..c0f9c6c 100644 --- a/sojorn_app/lib/services/local_key_backup_service.dart +++ b/sojorn_app/lib/services/local_key_backup_service.dart @@ -12,7 +12,11 @@ import 'package:file_picker/file_picker.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:universal_html/html.dart' as html; import 'package:universal_html/js.dart' as js; +import 'package:device_info_plus/device_info_plus.dart'; import '../../services/simple_e2ee_service.dart'; +import '../../services/local_message_store.dart'; +import '../../services/api_service.dart'; +import '../../services/auth_service.dart'; import '../../theme/app_theme.dart'; /// Local key backup service for device-based key storage @@ -29,12 +33,45 @@ class LocalKeyBackupService { static Future> createEncryptedBackup({ required String password, required SimpleE2EEService e2eeService, + bool includeKeys = true, + bool includeMessages = true, }) async { try { - print('[BACKUP] Creating encrypted backup...'); + print('[BACKUP] Creating encrypted backup (keys: $includeKeys, msgs: $includeMessages)...'); - // 1. Export all keys from E2EE service - final keyData = await _exportAllKeys(e2eeService); + // 1. Export keys (if requested) + Map? keyData; + if (includeKeys) { + keyData = await _exportAllKeys(e2eeService); + } + + // 1b. Export messages if requested + List>? messageData; + if (includeMessages) { + print('[BACKUP] Exporting messages...'); + final messages = await LocalMessageStore.instance.getAllMessageRecords(); + messageData = messages.map((m) => { + 'conversationId': m.conversationId, + 'messageId': m.messageId, + 'plaintext': m.plaintext, + 'senderId': m.senderId, + 'createdAt': m.createdAt.toIso8601String(), + 'messageType': m.messageType, + 'deliveredAt': m.deliveredAt?.toIso8601String(), + 'readAt': m.readAt?.toIso8601String(), + 'expiresAt': m.expiresAt?.toIso8601String(), + }).toList(); + print('[BACKUP] Exported ${messages.length} messages'); + } + + final payloadData = { + if (keyData != null) 'keys': keyData, + if (messageData != null) 'messages': messageData, + }; + + if (payloadData.isEmpty) { + throw ArgumentError('Backup must include either keys or messages'); + } // 2. Generate salt for key derivation final salt = _generateSalt(); @@ -47,7 +84,7 @@ class LocalKeyBackupService { final secretKey = SecretKey(encryptionKey); final nonce = _generateNonce(); - final plaintext = utf8.encode(jsonEncode(keyData)); + final plaintext = utf8.encode(jsonEncode(payloadData)); final secretBox = await algorithm.encrypt( plaintext, secretKey: secretKey, @@ -65,7 +102,8 @@ class LocalKeyBackupService { 'metadata': { 'app_name': 'Sojorn', 'platform': kIsWeb ? 'web' : defaultTargetPlatform.toString(), - 'key_count': keyData['keys']?.length ?? 0, + 'key_count': keyData?['keys']?.length ?? 0, + 'message_count': messageData?.length ?? 0, }, }; @@ -105,15 +143,43 @@ class LocalKeyBackupService { final secretBox = SecretBox(ciphertext, nonce: nonce, mac: mac); final plaintext = await algorithm.decrypt(secretBox, secretKey: secretKey); - final keyData = jsonDecode(utf8.decode(plaintext)); + final payloadData = jsonDecode(utf8.decode(plaintext)); - // 5. Import keys to E2EE service - await _importAllKeys(keyData, e2eeService); + // Handle legacy format (where root is keyData) or new format (where root has 'keys') + final keyData = payloadData.containsKey('keys') ? payloadData['keys'] : payloadData; + + // 5. Import keys to E2EE service (if present) + if (keyData != null) { + await _importAllKeys(keyData, e2eeService); + } + + // 6. Import messages if present + int restoredMessages = 0; + if (payloadData is Map && payloadData.containsKey('messages')) { + final messages = (payloadData['messages'] as List).cast>(); + print('[BACKUP] Restoring ${messages.length} messages...'); + + for (final m in messages) { + await LocalMessageStore.instance.saveMessageRecord(LocalMessageRecord( + conversationId: m['conversationId'], + messageId: m['messageId'], + plaintext: m['plaintext'], + senderId: m['senderId'], + createdAt: DateTime.parse(m['createdAt']), + messageType: m['messageType'], + deliveredAt: m['deliveredAt'] != null ? DateTime.parse(m['deliveredAt']) : null, + readAt: m['readAt'] != null ? DateTime.parse(m['readAt']) : null, + expiresAt: m['expiresAt'] != null ? DateTime.parse(m['expiresAt']) : null, + )); + } + restoredMessages = messages.length; + } print('[BACKUP] Backup restored successfully'); return { 'success': true, - 'restored_keys': keyData['keys']?.length ?? 0, + 'restored_keys': keyData != null ? (keyData['keys']?.length ?? 0) : 0, + 'restored_messages': restoredMessages, 'backup_date': backup['created_at'], }; @@ -331,9 +397,79 @@ class LocalKeyBackupService { } if (backup['version'] != _backupVersion) { + // Allow 1 if our current is 1.0 (lazy float check) + if (backup['version'].toString().startsWith('1')) return; throw ArgumentError('Unsupported backup version: ${backup['version']}'); } } + + /// Upload encrypted backup to cloud + static Future uploadToCloud({ + required Map backup, + }) async { + print('[BACKUP] Uploading to cloud...'); + + // Get device name + String deviceName = 'Unknown Device'; + if (!kIsWeb) { + final deviceInfo = DeviceInfoPlugin(); + if (Platform.isAndroid) { + final androidInfo = await deviceInfo.androidInfo; + deviceName = '${androidInfo.brand} ${androidInfo.model}'; + } else if (Platform.isIOS) { + final iosInfo = await deviceInfo.iosInfo; + deviceName = iosInfo.name; + } + } else { + deviceName = 'Web Browser'; + } + + await ApiService.instance.uploadBackup( + encryptedBlob: backup['ciphertext'], + salt: backup['salt'], + nonce: backup['nonce'], + mac: backup['mac'], + deviceName: deviceName, + version: 1, // Currently hardcoded version + ); + print('[BACKUP] Upload successful'); + } + + /// Restore from cloud backup + static Future> restoreFromCloud({ + required String password, + required SimpleE2EEService e2eeService, + String? backupId, + }) async { + print('[BACKUP] Downloading from cloud...'); + final backupData = await ApiService.instance.downloadBackup(backupId); + + if (backupData == null) { + throw Exception('No backup found'); + } + + // Reconstruct the backup map format expected by restoreFromBackup + final backup = { + 'version': backupData['version'].toString(), // Go sends int, we might need string + 'created_at': backupData['created_at'], + 'salt': backupData['salt'], + 'nonce': backupData['nonce'], + 'ciphertext': backupData['encrypted_blob'], // Go sends encrypted_blob + 'mac': backupData['mac'], + 'metadata': { + 'device_name': backupData['device_name'], + } + }; + + // Fix version type mismatch if needed (our constant is '1.0', Go might send 1) + if (backup['version'] == '1') backup['version'] = '1.0'; + + return await restoreFromBackup( + backup: backup, + password: password, + e2eeService: e2eeService, + ); + } } /// Screen for managing local key backups @@ -348,6 +484,9 @@ class _LocalBackupScreenState extends State { final SimpleE2EEService _e2eeService = SimpleE2EEService(); bool _isCreatingBackup = false; bool _isRestoringBackup = false; + bool _includeMessages = true; + bool _includeKeys = true; + bool _useCloud = false; // Toggle for Cloud vs Local String? _lastBackupPath; DateTime? _lastBackupDate; @@ -360,7 +499,7 @@ class _LocalBackupScreenState extends State { elevation: 0, surfaceTintColor: Colors.transparent, title: Text( - 'Key Backup', + 'Full Backup & Recovery', style: GoogleFonts.literata( fontWeight: FontWeight.w600, color: AppTheme.navyBlue, @@ -375,6 +514,8 @@ class _LocalBackupScreenState extends State { children: [ _buildInfoCard(), const SizedBox(height: 24), + _buildModeToggle(), + const SizedBox(height: 24), _buildCreateBackupSection(), const SizedBox(height: 24), _buildRestoreBackupSection(), @@ -386,6 +527,53 @@ class _LocalBackupScreenState extends State { ); } + Widget _buildModeToggle() { + return Container( + padding: EdgeInsets.all(4), + decoration: BoxDecoration( + color: AppTheme.cardSurface, + borderRadius: BorderRadius.circular(50), + border: Border.all(color: AppTheme.border), + ), + child: Row( + children: [ + _buildModeButton(title: 'Local File', isCloud: false), + _buildModeButton(title: 'Cloud Backup', isCloud: true), + ], + ), + ); + } + + Widget _buildModeButton({required String title, required bool isCloud}) { + final isSelected = _useCloud == isCloud; + return Expanded( + child: GestureDetector( + onTap: () => setState(() { + _useCloud = isCloud; + // Security Default: Don't send keys to cloud, do save keys locally + _includeKeys = !isCloud; + // UX Default: Always include messages for cloud (that's the point) + if (isCloud) _includeMessages = true; + }), + child: Container( + padding: EdgeInsets.symmetric(vertical: 12), + decoration: BoxDecoration( + color: isSelected ? AppTheme.brightNavy : Colors.transparent, + borderRadius: BorderRadius.circular(50), + ), + alignment: Alignment.center, + child: Text( + title, + style: GoogleFonts.inter( + fontWeight: FontWeight.w600, + color: isSelected ? Colors.white : AppTheme.textSecondary, + ), + ), + ), + ), + ); + } + Widget _buildInfoCard() { return Container( padding: const EdgeInsets.all(16), @@ -405,7 +593,7 @@ class _LocalBackupScreenState extends State { Icon(Icons.info_outline, color: AppTheme.brightNavy), const SizedBox(width: 8), Text( - 'Local Key Backup', + _useCloud ? 'Encrypted Cloud Backup' : 'Local Key Backup', style: GoogleFonts.literata( fontWeight: FontWeight.w600, color: AppTheme.brightNavy, @@ -416,8 +604,11 @@ class _LocalBackupScreenState extends State { ), const SizedBox(height: 8), Text( - 'Your encryption keys are saved locally on this device with password protection. ' - 'You can export them to a file for safekeeping or restore from a backup file.', + _useCloud + ? 'Your messages are encrypted with your password and stored safely on our secure servers. ' + 'We never store your encryption keys on the server. You MUST have a local backup of your keys to restore these messages.' + : 'Your encryption keys and message history are saved locally on this device. ' + 'Keep this file safe! It is the only way to restore your identity.', style: GoogleFonts.inter( color: AppTheme.textSecondary, fontSize: 14, @@ -442,7 +633,7 @@ class _LocalBackupScreenState extends State { Icon(Icons.backup, color: AppTheme.brightNavy), const SizedBox(width: 8), Text( - 'Create Backup', + _useCloud ? 'Upload to Cloud' : 'Create Backup', style: GoogleFonts.literata( fontWeight: FontWeight.w600, color: AppTheme.navyBlue, @@ -453,17 +644,72 @@ class _LocalBackupScreenState extends State { ), const SizedBox(height: 12), Text( - 'Export your encryption keys to a password-protected backup file.', + _useCloud + ? 'Encrypt and upload your message history to the cloud.' + : 'Export your keys and messages to a password-protected backup file.', style: GoogleFonts.inter( color: AppTheme.textSecondary, fontSize: 14, ), ), + const SizedBox(height: 12), + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: Text( + 'Include Message History', + style: GoogleFonts.inter( + color: AppTheme.navyBlue, + fontWeight: FontWeight.w500, + ), + ), + subtitle: Text( + 'Backup all your secure conversations', + style: GoogleFonts.inter(fontSize: 12, color: AppTheme.textDisabled), + ), + value: _includeMessages, + onChanged: (v) => setState(() => _includeMessages = v), + activeColor: AppTheme.brightNavy, + ), + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: Text( + 'Include Encryption Keys', + style: GoogleFonts.inter( + color: _useCloud ? AppTheme.error : AppTheme.navyBlue, // Warn if cloud + fontWeight: FontWeight.w500, + ), + ), + subtitle: Text( + _useCloud + ? 'NOT RECOMMENDED for cloud backups. Keep keys local.' + : 'Required to restore your identity on a new device', + style: GoogleFonts.inter(fontSize: 12, color: AppTheme.textDisabled), + ), + value: _includeKeys, + onChanged: (v) => setState(() => _includeKeys = v), + activeColor: _useCloud ? Colors.red : AppTheme.brightNavy, + ), + if (_useCloud && !_includeKeys) + Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + children: [ + Icon(Icons.security, size: 16, color: Colors.green), + SizedBox(width: 8), + Expanded( + child: Text( + 'Secure Mode: Zero Knowledge. Server cannot decrypt.', + style: GoogleFonts.inter(fontSize: 12, color: Colors.green), + ), + ), + ], + ), + ), const SizedBox(height: 16), SizedBox( width: double.infinity, child: ElevatedButton.icon( - onPressed: _isCreatingBackup ? null : _createBackup, + onPressed: _isCreatingBackup ? null : (_useCloud ? _createCloudBackup : _createBackup), icon: _isCreatingBackup ? SizedBox( width: 16, @@ -473,8 +719,8 @@ class _LocalBackupScreenState extends State { valueColor: AlwaysStoppedAnimation(Colors.white), ), ) - : Icon(Icons.file_download), - label: Text(_isCreatingBackup ? 'Creating...' : 'Export Backup'), + : Icon(_useCloud ? Icons.cloud_upload : Icons.file_download), + label: Text(_isCreatingBackup ? 'Processing...' : (_useCloud ? 'Upload Backup' : 'Export Backup')), style: ElevatedButton.styleFrom( backgroundColor: AppTheme.brightNavy, foregroundColor: Colors.white, @@ -501,7 +747,7 @@ class _LocalBackupScreenState extends State { Icon(Icons.restore, color: AppTheme.brightNavy), const SizedBox(width: 8), Text( - 'Restore Backup', + _useCloud ? 'Download & Restore' : 'Restore Backup', style: GoogleFonts.literata( fontWeight: FontWeight.w600, color: AppTheme.navyBlue, @@ -512,7 +758,9 @@ class _LocalBackupScreenState extends State { ), const SizedBox(height: 12), Text( - 'Import your encryption keys from a backup file.', + _useCloud + ? 'Download and decrypt the latest backup from the cloud.' + : 'Import your encryption keys from a backup file.', style: GoogleFonts.inter( color: AppTheme.textSecondary, fontSize: 14, @@ -522,7 +770,7 @@ class _LocalBackupScreenState extends State { SizedBox( width: double.infinity, child: ElevatedButton.icon( - onPressed: _isRestoringBackup ? null : _restoreBackup, + onPressed: _isRestoringBackup ? null : (_useCloud ? _restoreCloudBackup : _restoreBackup), icon: _isRestoringBackup ? SizedBox( width: 16, @@ -532,8 +780,8 @@ class _LocalBackupScreenState extends State { valueColor: AlwaysStoppedAnimation(Colors.white), ), ) - : Icon(Icons.file_upload), - label: Text(_isRestoringBackup ? 'Restoring...' : 'Import Backup'), + : Icon(_useCloud ? Icons.cloud_download : Icons.file_upload), + label: Text(_isRestoringBackup ? 'Restoring...' : (_useCloud ? 'Download & Restore' : 'Import Backup')), style: ElevatedButton.styleFrom( backgroundColor: AppTheme.royalPurple, foregroundColor: Colors.white, @@ -590,6 +838,78 @@ class _LocalBackupScreenState extends State { ); } + Future _createCloudBackup() async { + try { + setState(() => _isCreatingBackup = true); + + final password = await _showPasswordDialog('Encrypt Cloud Backup'); + if (password == null) return; + + final backup = await LocalKeyBackupService.createEncryptedBackup( + password: password, + e2eeService: _e2eeService, + includeMessages: _includeMessages, + includeKeys: _includeKeys, // Default false for cloud + ); + + await LocalKeyBackupService.uploadToCloud(backup: backup); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Backup uploaded securely!'), + backgroundColor: Colors.green, + ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Upload failed: $e'), + backgroundColor: Colors.red, + ), + ); + } + } finally { + setState(() => _isCreatingBackup = false); + } + } + + Future _restoreCloudBackup() async { + try { + setState(() => _isRestoringBackup = true); + + final password = await _showPasswordDialog('Decrypt Cloud Backup'); + if (password == null) return; + + final result = await LocalKeyBackupService.restoreFromCloud( + password: password, + e2eeService: _e2eeService, + ); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Backup restored! ${result['restored_keys']} keys, ${result['restored_messages']} messages.'), + backgroundColor: Colors.green, + ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Restore failed: $e'), + backgroundColor: Colors.red, + ), + ); + } + } finally { + setState(() => _isRestoringBackup = false); + } + } + Future _createBackup() async { try { setState(() => _isCreatingBackup = true); @@ -602,6 +922,8 @@ class _LocalBackupScreenState extends State { final backup = await LocalKeyBackupService.createEncryptedBackup( password: password, e2eeService: _e2eeService, + includeMessages: _includeMessages, + includeKeys: _includeKeys, // Should be true for local ); // Save to device @@ -656,7 +978,7 @@ class _LocalBackupScreenState extends State { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Backup restored successfully! ${result['restored_keys']} keys recovered.'), + content: Text('Backup restored successfully! ${result['restored_keys']} keys and ${result['restored_messages']} messages recovered.'), backgroundColor: Colors.green, ), ); diff --git a/sojorn_app/lib/services/local_message_store.dart b/sojorn_app/lib/services/local_message_store.dart index 4e2f532..8327e6b 100644 --- a/sojorn_app/lib/services/local_message_store.dart +++ b/sojorn_app/lib/services/local_message_store.dart @@ -384,6 +384,44 @@ class LocalMessageStore { return results; } + /// Get ALL message records across ALL conversations (for backup). + Future> getAllMessageRecords() async { + final results = []; + try { + await _ensureBoxes(); + final allConversations = await getAllConversationIds(); + + for (final conversationId in allConversations) { + final messages = await getMessageRecordsForConversation(conversationId, limit: 10000); // High limit for backup + results.addAll(messages); + } + } catch (e) { + print('[LOCAL_STORE] Failed to get all messages: $e'); + } + return results; + } + + /// Get all conversation IDs. + Future> getAllConversationIds() async { + await _ensureBoxes(); + return _conversationBox!.keys.cast().toList(); + } + + /// Save a raw message record (for restore). + Future saveMessageRecord(LocalMessageRecord record) async { + return saveMessage( + conversationId: record.conversationId, + messageId: record.messageId, + plaintext: record.plaintext, + senderId: record.senderId, + createdAt: record.createdAt, + messageType: record.messageType, + deliveredAt: record.deliveredAt, + readAt: record.readAt, + expiresAt: record.expiresAt, + ); + } + /// Get list of message IDs for a conversation. Future> getMessageIdsForConversation(String conversationId) async { try { diff --git a/sojorn_app/lib/services/simple_e2ee_service.dart b/sojorn_app/lib/services/simple_e2ee_service.dart index 76d2a47..fba3c22 100644 --- a/sojorn_app/lib/services/simple_e2ee_service.dart +++ b/sojorn_app/lib/services/simple_e2ee_service.dart @@ -658,7 +658,7 @@ class SimpleE2EEService { // Automatic MAC error handling int _macErrorCount = 0; - static const int _maxMacErrors = 3; + static const int _maxMacErrors = 50; DateTime? _lastMacErrorTime; void _handleMacError() { @@ -949,6 +949,7 @@ class SimpleE2EEService { otkData.add({ 'key_id': i, 'public_key': base64Encode(otkPublic.bytes), + 'private_key': base64Encode(await otk.extractPrivateKeyBytes()), }); } @@ -990,38 +991,70 @@ class SimpleE2EEService { final keys = backupData['keys'] as Map; - // For now, we'll generate new keys since the cryptography library - // doesn't support direct private key import - // In a production app, you'd need a more sophisticated key import system - - print('[E2EE] Note: Generating new keys due to import limitations'); - - // Generate new identity keys - _identityDhKeyPair = await _dhAlgo.newKeyPair(); - _identitySigningKeyPair = await _signingAlgo.newKeyPair(); - - // Generate new signed prekey - _signedPreKey = await _dhAlgo.newKeyPair(); - final spkPublic = await _signedPreKey!.extractPublicKey(); - final spkSignature = await _signingAlgo.sign(spkPublic.bytes, keyPair: _identitySigningKeyPair!); - - // Generate new OTKs - final importedOTKs = []; - for (int i = 0; i < 20; i++) { - importedOTKs.add(await _dhAlgo.newKeyPair()); + // 1. Restore Identity Keys + if (keys.containsKey('identity_dh_private')) { + print('[E2EE] Restoring Identity DH key...'); + _identityDhKeyPair = await _dhAlgo.newKeyPairFromSeed(base64Decode(keys['identity_dh_private'])); } - _oneTimePreKeys = importedOTKs; - // Save locally and republish to server + if (keys.containsKey('identity_signing_private')) { + print('[E2EE] Restoring Identity Signing key...'); + _identitySigningKeyPair = await _signingAlgo.newKeyPairFromSeed(base64Decode(keys['identity_signing_private'])); + } + + // 2. Restore Signed PreKey + if (keys.containsKey('signed_prekey_private')) { + print('[E2EE] Restoring Signed PreKey...'); + _signedPreKey = await _dhAlgo.newKeyPairFromSeed(base64Decode(keys['signed_prekey_private'])); + } + + // 3. Restore One-Time PreKeys + if (keys.containsKey('one_time_prekeys') && keys['one_time_prekeys'] is List) { + final otkList = keys['one_time_prekeys'] as List; + final importedOTKs = []; + for (final item in otkList) { + if (item is Map && item.containsKey('private_key')) { + importedOTKs.add(await _dhAlgo.newKeyPairFromSeed(base64Decode(item['private_key']))); + } + } + _oneTimePreKeys = importedOTKs; + print('[E2EE] Restored ${_oneTimePreKeys!.length} OTKs'); + } + + // 4. Set User Context from metadata + if (backupData.containsKey('metadata')) { + final metadata = backupData['metadata'] as Map; + if (metadata.containsKey('user_id')) { + _initializedForUserId = metadata['user_id']; + } + } + + // Fallback if metadata missing + if (_initializedForUserId == null) { + _initializedForUserId = _auth.currentUser?.id; + } + + // 5. Persist and Synchronize if (_initializedForUserId != null) { + print('[E2EE] Persisting restored keys to local storage...'); await _saveKeysToLocal(_initializedForUserId!); - await _publishKeys(spkSignature.bytes); + + // Republish to server to ensure backend is synchronized + // This is safe even if keys are identical + if (_identitySigningKeyPair != null && _signedPreKey != null) { + final spkPublic = await _signedPreKey!.extractPublicKey(); + final signature = await _signingAlgo.sign( + spkPublic.bytes, + keyPair: _identitySigningKeyPair! + ); + await _publishKeys(signature.bytes); + } } - print('[E2EE] New keys generated and imported successfully'); + print('[E2EE] Backup restoration complete. Old messages can now be decrypted.'); } catch (e) { - print('[E2EE] Failed to import keys: $e'); + print('[E2EE] CRITICAL: Failed to import keys: $e'); rethrow; } } diff --git a/sojorn_app/lib/widgets/app_scaffold.dart b/sojorn_app/lib/widgets/app_scaffold.dart index c5b9e39..3afb0d4 100644 --- a/sojorn_app/lib/widgets/app_scaffold.dart +++ b/sojorn_app/lib/widgets/app_scaffold.dart @@ -80,7 +80,9 @@ class AppScaffold extends StatelessWidget { PreferredSizeWidget _buildDefaultAppBar(BuildContext context) { return AppBar( - title: title.isEmpty ? Image.asset('assets/images/toplogo.png') : Text(title), + title: title.isEmpty + ? Image.asset('assets/images/toplogo.png', height: 40) + : Text(title), centerTitle: centerTitle, leading: leading ?? _buildBackButton(context), actions: actions, diff --git a/sojorn_app/lib/widgets/chain_quote_widget.dart b/sojorn_app/lib/widgets/chain_quote_widget.dart index f9d522a..83efa10 100644 --- a/sojorn_app/lib/widgets/chain_quote_widget.dart +++ b/sojorn_app/lib/widgets/chain_quote_widget.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import '../models/post.dart'; import '../theme/app_theme.dart'; import 'media/signed_media_image.dart'; +import 'reactions/reactions_display.dart'; class ChainQuoteWidget extends StatelessWidget { final PostPreview parent; @@ -131,6 +132,15 @@ class ChainQuoteWidget extends StatelessWidget { color: AppTheme.postContentLight, ), ), + if (parent.reactions != null && parent.reactions!.isNotEmpty) ...[ + const SizedBox(height: 8), + ReactionsDisplay( + reactionCounts: parent.reactions!, + myReactions: parent.myReactions?.toSet() ?? {}, + mode: ReactionsDisplayMode.compact, + padding: EdgeInsets.zero, + ), + ], ], ), ), diff --git a/sojorn_app/lib/widgets/kinetic_thread_widget.dart b/sojorn_app/lib/widgets/kinetic_thread_widget.dart index 4a1b0d7..a65f19e 100644 --- a/sojorn_app/lib/widgets/kinetic_thread_widget.dart +++ b/sojorn_app/lib/widgets/kinetic_thread_widget.dart @@ -13,6 +13,7 @@ import '../models/thread_node.dart'; import '../providers/api_provider.dart'; import '../theme/app_theme.dart'; import '../widgets/media/signed_media_image.dart'; +import '../widgets/reactions/reactions_display.dart'; /// Kinetic Spatial Engine widget for layer-based thread navigation class KineticThreadWidget extends ConsumerStatefulWidget { @@ -1011,6 +1012,16 @@ class _KineticThreadWidgetState extends ConsumerState fontSize: 10, ), ), + if (child.post.reactions != null && child.post.reactions!.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 4), + child: ReactionsDisplay( + reactionCounts: child.post.reactions!, + myReactions: child.post.myReactions?.toSet() ?? {}, + mode: ReactionsDisplayMode.compact, + padding: EdgeInsets.zero, + ), + ), ], ), ), diff --git a/sojorn_app/lib/widgets/post/post_actions.dart b/sojorn_app/lib/widgets/post/post_actions.dart index 686008f..0097c83 100644 --- a/sojorn_app/lib/widgets/post/post_actions.dart +++ b/sojorn_app/lib/widgets/post/post_actions.dart @@ -8,7 +8,6 @@ import '../../providers/api_provider.dart'; import '../../theme/app_theme.dart'; import '../sojorn_snackbar.dart'; import '../reactions/reaction_picker.dart'; -import '../reactions/smart_reaction_button.dart'; import '../reactions/reactions_display.dart'; /// Post actions with a vibrant, clear, and energetic design. @@ -51,7 +50,18 @@ class _PostActionsState extends ConsumerState { _seedReactionState(); } + @override + void didUpdateWidget(covariant PostActions oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.post != oldWidget.post) { + _isSaved = widget.post.isSaved ?? false; + _seedReactionState(); + } + } + void _seedReactionState() { + _reactionCounts.clear(); + _myReactions.clear(); if (widget.post.reactions != null) { _reactionCounts.addAll(widget.post.reactions!); } @@ -207,13 +217,13 @@ class _PostActionsState extends ConsumerState { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Show all reactions in thread view if (widget.showReactions && _reactionCounts.isNotEmpty) ReactionsDisplay( reactionCounts: _reactionCounts, myReactions: _myReactions, - onReactionTap: _showReactionPicker, - showAll: true, + onToggleReaction: _toggleReaction, + onAddReaction: _showReactionPicker, + mode: ReactionsDisplayMode.full, ), // Actions row - reply moved to right @@ -256,11 +266,13 @@ class _PostActionsState extends ConsumerState { // Right side: Reply and Reactions Row( children: [ - // Smart reaction button (replaces appreciate button) - SmartReactionButton( + // Single Authority: ReactionsDisplay in compact mode for the actions row + ReactionsDisplay( reactionCounts: _reactionCounts, myReactions: _myReactions, - onPressed: _showReactionPicker, + onToggleReaction: _toggleReaction, + onAddReaction: _showReactionPicker, + mode: ReactionsDisplayMode.compact, ), const SizedBox(width: 8), if (allowChain) diff --git a/sojorn_app/lib/widgets/post_with_video_widget.dart b/sojorn_app/lib/widgets/post_with_video_widget.dart index a6ffec2..e6a6516 100644 --- a/sojorn_app/lib/widgets/post_with_video_widget.dart +++ b/sojorn_app/lib/widgets/post_with_video_widget.dart @@ -3,9 +3,9 @@ import 'package:google_fonts/google_fonts.dart'; import 'package:timeago/timeago.dart' as timeago; import '../../models/post.dart'; import '../../theme/app_theme.dart'; -import '../media/signed_media_image.dart'; -import '../video_thumbnail_widget.dart'; -import '../post/post_actions.dart'; +import 'media/signed_media_image.dart'; +import 'video_thumbnail_widget.dart'; +import 'post/post_actions.dart'; /// Enhanced post widget with video thumbnail support (Twitter-style) class PostWithVideoWidget extends StatelessWidget { diff --git a/sojorn_app/lib/widgets/reactions/reaction_picker.dart b/sojorn_app/lib/widgets/reactions/reaction_picker.dart index 2dfca26..40e9d58 100644 --- a/sojorn_app/lib/widgets/reactions/reaction_picker.dart +++ b/sojorn_app/lib/widgets/reactions/reaction_picker.dart @@ -2,7 +2,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter/services.dart'; import 'package:flutter_svg/flutter_svg.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; import 'package:google_fonts/google_fonts.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'dart:convert'; import '../../theme/app_theme.dart'; class ReactionPicker extends StatefulWidget { @@ -45,174 +48,106 @@ class _ReactionPickerState extends State with SingleTickerProvid _loadReactionSets(); } + + Future _loadReactionSets() async { - // Start with emoji set - final reactionSets = >{ - 'emoji': [ - '❤️', '👍', '😂', '😮', '😢', '😡', - '🎉', '🔥', '👏', '🙏', '💯', '🤔', - '😍', '🤣', '😊', '👌', '🙌', '💪', - '🎯', '⭐', '✨', '🌟', '💫', '☀️', - ], - }; - - final folderCredits = {}; - final tabOrder = ['emoji']; // Start with emoji, will add folders after - - // Known reaction folders to check - final knownFolders = ['dotto', 'green', 'blue', 'purple']; - - for (final folder in knownFolders) { try { - // Try to load credit file - final creditContent = await _loadCreditFile(folder); - folderCredits[folder] = creditContent; + final reactionSets = >{ + 'emoji': [ + '❤️', '👍', '😂', '😮', '😢', '😡', + '🎉', '🔥', '👏', '🙏', '💯', '🤔', + '😍', '🤣', '😊', '👌', '🙌', '💪', + '🎯', '⭐', '✨', '🌟', '💫', '☀️', + ], + }; - // Try to load files from this folder - final reactions = await _loadAllFilesFromFolder(folder); + final folderCredits = {}; + final tabOrder = ['emoji']; + + // Load the manifest to discover assets + final manifest = await AssetManifest.loadFromAssetBundle(rootBundle); + final assetPaths = manifest.listAssets(); - if (reactions.isNotEmpty) { - reactionSets[folder] = reactions; - tabOrder.add(folder); + // Filter for reaction assets + final reactionAssets = assetPaths.where((path) { + final lowerPath = path.toLowerCase(); + return lowerPath.startsWith('assets/reactions/') && + (lowerPath.endsWith('.png') || + lowerPath.endsWith('.svg') || + lowerPath.endsWith('.webp') || + lowerPath.endsWith('.jpg') || + lowerPath.endsWith('.jpeg') || + lowerPath.endsWith('.gif')); + }).toList(); + + for (final path in reactionAssets) { + // Path format: assets/reactions/FOLDER_NAME/FILE_NAME.ext + final parts = path.split('/'); + if (parts.length >= 4) { + final folderName = parts[2]; + + if (!reactionSets.containsKey(folderName)) { + reactionSets[folderName] = []; + tabOrder.add(folderName); + + // Try to load credit file if it's the first time we see this folder + try { + final creditPath = 'assets/reactions/$folderName/credit.md'; + // Check if credit file exists in manifest too + if (assetPaths.contains(creditPath)) { + final creditData = await rootBundle.loadString(creditPath); + folderCredits[folderName] = creditData; + } + } catch (e) { + // Ignore missing credit files + } + } + + reactionSets[folderName]!.add(path); + } + } + + // Sort reactions within each set by file name + for (final key in reactionSets.keys) { + if (key != 'emoji') { + reactionSets[key]!.sort((a, b) => a.split('/').last.compareTo(b.split('/').last)); + } + } + + if (mounted) { + setState(() { + _reactionSets = reactionSets; + _folderCredits = folderCredits; + _tabOrder = tabOrder; + _isLoading = false; + + _tabController = TabController(length: _tabOrder.length, vsync: this); + _tabController.addListener(() { + if (mounted) { + setState(() { + _currentTabIndex = _tabController.index; + _clearSearch(); + }); + } + }); + }); } } catch (e) { - // Error loading folder, skip it - } - } - - setState(() { - _reactionSets = reactionSets; - _folderCredits = folderCredits; - _tabOrder = tabOrder; - _isLoading = false; - - // Create TabController after setting up the data - _tabController = TabController(length: _tabOrder.length, vsync: this); - _tabController.addListener(() { - setState(() { - _currentTabIndex = _tabController.index; - _clearSearch(); - }); - }); - }); -} - -Future> _loadAllFilesFromFolder(String folder) async { - final reactions = []; - - // Common file names to try (comprehensive list) - final possibleFiles = [ - // Basic reactions - 'heart', 'thumbs_up', 'laugh', 'lol', 'wow', 'sad', 'angry', 'mad', - 'party', 'fire', 'clap', 'pray', 'hundred', 'thinking', 'ok', - - // Face expressions - 'smile', 'happy', 'grinning', 'beaming', 'wink', 'kiss', 'love', - 'laughing', 'crying', 'tears', 'joy', 'giggle', 'chuckle', - 'frown', 'worried', 'scared', 'fear', 'shock', 'surprised', - 'confused', 'thinking_face', 'face_palm', 'eyeroll', - - // Extended face names - 'laughing_face', 'beaming_face', 'face_with_tears', 'grinning_face', - 'smiling_face', 'winking_face', 'melting_face', 'upside_down_face', - 'rolling_face', 'slightly_smiling_face', 'smiling_face_with_halo', - 'smiling_face_with_hearts', 'face_with_monocle', 'nerd_face', - 'party_face', 'sunglasses_face', 'disappointed_face', 'worried_face', - 'anguished_face', 'fearful_face', 'downcast_face', 'loudly_crying_face', - - // Special characters - 'skull', 'ghost', 'robot', 'alien', 'monster', 'devil', 'angel', - 'poop', 'vomit', 'sick', 'dizzy', 'sleeping', 'zzz', - - // Hearts and love - 'heart_with_arrow', 'broken_heart', 'sparkling_heart', 'two_hearts', - 'revolving_hearts', 'heart_eyes', 'kissing_heart', - - // Colored hearts - 'green_heart', 'blue_heart', 'purple_heart', 'yellow_heart', - 'black_heart', 'white_heart', 'brown_heart', 'orange_heart', - - // Actions and objects - 'thumbs_down', 'ok_hand', 'peace', 'victory', 'rock_on', 'call_me', - 'point_up', 'point_down', 'point_left', 'point_right', - 'raised_hand', 'wave', 'clap', 'high_five', 'pray', 'namaste', - - // Nature and elements - 'fire', 'water', 'earth', 'air', 'lightning', 'storm', 'rainbow', - 'sun', 'moon', 'star', 'cloud', 'tree', 'flower', 'leaf', - - // Food and drink - 'pizza', 'burger', 'taco', 'ice_cream', 'coffee', 'tea', 'beer', - 'wine', 'cocktail', 'cake', 'cookie', 'candy', 'chocolate', - - // Animals - 'dog', 'cat', 'mouse', 'rabbit', 'bear', 'lion', 'tiger', 'elephant', - 'monkey', 'bird', 'fish', 'butterfly', 'spider', 'snake', - - // Objects and symbols - 'bomb', 'knife', 'gun', 'pistol', 'sword', 'shield', 'crown', - 'gem', 'diamond', 'money', 'coin', 'dollar', 'gift', 'present', - - // Numbers and symbols - 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine', 'ten', - 'zero', 'plus', 'minus', 'multiply', 'divide', 'equal', 'check', 'x', - - // Common variations with underscores - 'thumbs_up', 'thumbs_down', 'middle_finger', 'rock_on', 'peace_sign', - 'ok_sign', 'victory_sign', 'call_me_hand', 'raised_hand', 'wave_hand', - - // Emoji-style names - 'face_with_open_mouth', 'face_with_closed_eyes', 'face_with_tears_of_joy', - 'grinning_face_with_big_eyes', 'grinning_face_with_smiling_eyes', - 'grinning_face_with_sweat', 'grinning_squinting_face', 'hugging_face', - 'face_with_head_bandage', 'face_with_thermometer', 'face_with_bandage', - 'nauseated_face', 'sneezing_face', 'yawning_face', 'face_with_cowboy_hat', - - // More descriptive names - 'party_popper', 'confetti_ball', 'balloon', 'ribbon', 'gift_ribbon', - 'birthday_cake', 'wedding_cake', 'christmas_tree', 'pumpkin', 'ghost_halloween', - - // Simple variations - 'like', 'dislike', 'love', 'hate', 'yes', 'no', 'maybe', 'idk', - 'cool', 'hot', 'cold', 'warm', 'fresh', 'old', 'new', 'classic', - - // Tech and modern - 'computer', 'phone', 'camera', 'video', 'music', 'game', 'controller', - 'mouse', 'keyboard', 'screen', 'monitor', 'laptop', 'tablet', - - // Expressions - 'lol', 'lmao', 'rofl', 'omg', 'wtf', 'smh', 'idc', 'ngl', 'fr', - 'tbh', 'iykyk', 'rn', 'asap', 'fyi', 'btw', 'imo', 'imho', - ]; - - // Try both PNG and SVG extensions for each possible file name - for (final fileName in possibleFiles) { - for (final extension in ['png', 'svg']) { - final fullPath = 'reactions/$folder/$fileName.$extension'; - try { - // Try to load the file to check if it exists - await rootBundle.load(fullPath); - final assetPath = 'assets/$fullPath'; - reactions.add(assetPath); - break; // Found this file, don't try other extensions - } catch (e) { - // File doesn't exist, try next extension + print('[REACTIONS] Error scanning assets: $e'); + // Fallback + if (mounted) { + setState(() { + _reactionSets = { + 'emoji': ['❤️', '👍', '😂', '😮', '😢', '😡'] + }; + _tabOrder = ['emoji']; + _isLoading = false; + _tabController = TabController(length: 1, vsync: this); + }); } } } - - return reactions; -} - Future _loadCreditFile(String folder) async { - try { - final creditData = await rootBundle.loadString('reactions/$folder/credit.md'); - return creditData; - } catch (e) { - // Return default credit if file not found - return '# $folder Reaction Set\n\nCustom reaction set for Sojorn'; - } - } @override void dispose() { @@ -447,9 +382,7 @@ Future> _loadAllFilesFromFolder(String folder) async { // Reaction grid SizedBox( - height: _isSearching && _filteredReactions.isNotEmpty - ? (_filteredReactions.length / 6).ceil() * 60 - : 240, // Dynamic height based on search results + height: 420, // Increased height to show more rows at once child: TabBarView( controller: _tabController, children: _tabOrder.map((tabName) { @@ -461,7 +394,7 @@ Future> _loadAllFilesFromFolder(String folder) async { children: [ // Reaction grid Expanded( - child: _buildReactionGrid(reactions, widget.reactionCounts ?? {}, widget.myReactions ?? {}, isEmoji), + child: _buildReactionGrid(reactions, widget.reactionCounts ?? {}, widget.myReactions ?? {}, !isEmoji), ), // Credit section (only for non-emoji tabs) @@ -500,71 +433,23 @@ Future> _loadAllFilesFromFolder(String folder) async { } Widget _buildCreditDisplay(String credit) { - final lines = credit.split('\n'); - final widgets = []; - - for (final line in lines) { - if (line.trim().isEmpty) continue; - - if (line.startsWith('# ')) { - // Title - widgets.add(Text( - line.substring(2).trim(), - style: GoogleFonts.inter( - fontSize: 11, - fontWeight: FontWeight.w600, - color: AppTheme.textPrimary, - ), - )); - } else if (line.startsWith('**') && line.endsWith('**')) { - // Bold text - widgets.add(Text( - line.replaceAll('**', '').trim(), - style: GoogleFonts.inter( - fontSize: 10, - fontWeight: FontWeight.w600, - color: AppTheme.textPrimary, - ), - )); - } else if (line.startsWith('*') && line.endsWith('*')) { - // Italic text - widgets.add(Text( - line.replaceAll('*', '').trim(), - style: GoogleFonts.inter( - fontSize: 10, - fontStyle: FontStyle.italic, - color: AppTheme.textPrimary, - ), - )); - } else if (line.startsWith('- ')) { - // List item - widgets.add(Padding( - padding: const EdgeInsets.only(left: 8), - child: Text( - '• ${line.substring(2).trim()}', - style: GoogleFonts.inter( - fontSize: 10, - color: AppTheme.textPrimary, - ), - ), - )); - } else { - // Regular text - widgets.add(Text( - line.trim(), - style: GoogleFonts.inter( - fontSize: 10, - color: AppTheme.textPrimary, - ), - )); - } - - widgets.add(const SizedBox(height: 2)); - } - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: widgets, + return MarkdownBody( + data: credit, + selectable: true, + onTapLink: (text, href, title) { + if (href != null) { + launchUrl(Uri.parse(href)); + } + }, + styleSheet: MarkdownStyleSheet( + p: GoogleFonts.inter(fontSize: 10, color: AppTheme.textPrimary), + h1: GoogleFonts.inter(fontSize: 12, fontWeight: FontWeight.bold, color: AppTheme.textPrimary), + h2: GoogleFonts.inter(fontSize: 11, fontWeight: FontWeight.bold, color: AppTheme.textPrimary), + listBullet: GoogleFonts.inter(fontSize: 10, color: AppTheme.textPrimary), + strong: GoogleFonts.inter(fontSize: 10, fontWeight: FontWeight.bold, color: AppTheme.textPrimary), + em: GoogleFonts.inter(fontSize: 10, fontStyle: FontStyle.italic, color: AppTheme.textPrimary), + a: GoogleFonts.inter(fontSize: 10, color: AppTheme.brightNavy, decoration: TextDecoration.underline), + ), ); } @@ -575,8 +460,8 @@ Future> _loadAllFilesFromFolder(String folder) async { bool useImages, ) { return GridView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), + padding: const EdgeInsets.all(4), + physics: const BouncingScrollPhysics(), gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 6, crossAxisSpacing: 8, @@ -594,7 +479,10 @@ Future> _loadAllFilesFromFolder(String folder) async { child: InkWell( onTap: () { Navigator.of(context).pop(); - widget.onReactionSelected(reaction); + final result = reaction.startsWith('assets/') + ? 'asset:$reaction' + : reaction; + widget.onReactionSelected(result); }, borderRadius: BorderRadius.circular(12), child: Container( @@ -653,7 +541,11 @@ Future> _loadAllFilesFromFolder(String folder) async { ); } - Widget _buildImageReaction(String imagePath) { + Widget _buildImageReaction(String reaction) { + final imagePath = reaction.startsWith('asset:') + ? reaction.replaceFirst('asset:', '') + : reaction; + if (imagePath.endsWith('.svg')) { return SvgPicture.asset( imagePath, @@ -669,7 +561,6 @@ Future> _loadAllFilesFromFolder(String folder) async { ), ), errorBuilder: (context, error, stackTrace) { - // Fallback to emoji if image not found return Icon( Icons.image_not_supported, size: 24, diff --git a/sojorn_app/lib/widgets/reactions/reaction_strip.dart b/sojorn_app/lib/widgets/reactions/reaction_strip.dart index 4e38078..7b5d6cc 100644 --- a/sojorn_app/lib/widgets/reactions/reaction_strip.dart +++ b/sojorn_app/lib/widgets/reactions/reaction_strip.dart @@ -4,8 +4,11 @@ import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_animate/flutter_animate.dart'; +import 'package:flutter_svg/flutter_svg.dart'; import '../../theme/app_theme.dart'; + + class ReactionStrip extends StatelessWidget { final Map reactions; final Set myReactions; @@ -194,8 +197,19 @@ class _ReactionIcon extends StatelessWidget { @override Widget build(BuildContext context) { - if (reactionId.startsWith('asset:')) { - final assetPath = reactionId.replaceFirst('asset:', ''); + if (reactionId.startsWith('asset:') || reactionId.startsWith('assets/')) { + final assetPath = reactionId.startsWith('asset:') + ? reactionId.replaceFirst('asset:', '') + : reactionId; + + if (assetPath.endsWith('.svg')) { + return SvgPicture.asset( + assetPath, + width: 18, + height: 18, + placeholderBuilder: (_) => const SizedBox(width: 18, height: 18), + ); + } return Image.asset( assetPath, width: 18, @@ -240,10 +254,8 @@ class _ReactionPickerSheetState extends State<_ReactionPickerSheet> { Future _loadAssetReactions() async { try { - final manifest = await DefaultAssetBundle.of(context) - .loadString('AssetManifest.json'); - final map = jsonDecode(manifest) as Map; - final keys = map.keys + final manifest = await AssetManifest.loadFromAssetBundle(DefaultAssetBundle.of(context)); + final keys = manifest.listAssets() .where((key) => key.startsWith('assets/reactions/')) .toList() ..sort(); diff --git a/sojorn_app/lib/widgets/reactions/reactions_display.dart b/sojorn_app/lib/widgets/reactions/reactions_display.dart index cb52bfb..c9e4a37 100644 --- a/sojorn_app/lib/widgets/reactions/reactions_display.dart +++ b/sojorn_app/lib/widgets/reactions/reactions_display.dart @@ -1,83 +1,256 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:google_fonts/google_fonts.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:flutter_animate/flutter_animate.dart'; import '../../theme/app_theme.dart'; -/// Displays all reactions for a post -/// Used in thread/detail views to show comprehensive reaction breakdown +enum ReactionsDisplayMode { + /// Comprehensive list of all reactions (Thread view) + full, + /// Single prioritized reaction chip (Feed view) + compact, +} + +/// Single Authority for reaction presentation and interaction. +/// +/// Handles: +/// - [ReactionsDisplayMode.full]: Multiple chips with optional 'Add' button. +/// - [ReactionsDisplayMode.compact]: Single prioritized chip. class ReactionsDisplay extends StatelessWidget { final Map reactionCounts; final Set myReactions; - final VoidCallback? onReactionTap; - final bool showAll; + final Map>? reactionUsers; + final Function(String)? onToggleReaction; + final VoidCallback? onAddReaction; + final ReactionsDisplayMode mode; + final EdgeInsets? padding; const ReactionsDisplay({ super.key, required this.reactionCounts, required this.myReactions, - this.onReactionTap, - this.showAll = true, + this.reactionUsers, + this.onToggleReaction, + this.onAddReaction, + this.mode = ReactionsDisplayMode.full, + this.padding, }); @override Widget build(BuildContext context) { - if (reactionCounts.isEmpty) { + if (reactionCounts.isEmpty && onAddReaction == null) { return const SizedBox.shrink(); } - List> reactions; - if (showAll) { - reactions = reactionCounts.entries.toList(); - reactions.sort((a, b) => b.value.compareTo(a.value)); - } else { - reactions = reactionCounts.entries.take(3).toList(); - reactions.sort((a, b) => b.value.compareTo(a.value)); + if (mode == ReactionsDisplayMode.compact) { + return _buildCompactView(); } + return _buildFullView(); + } + + Widget _buildCompactView() { + if (reactionCounts.isEmpty) { + return _ReactionAddButton(onTap: onAddReaction ?? () {}); + } + + // Priority: User's reaction > Top reaction + String? displayEmoji; + if (myReactions.isNotEmpty) { + displayEmoji = myReactions.first; + } else { + displayEmoji = reactionCounts.entries + .reduce((a, b) => a.value > b.value ? a : b) + .key; + } + + return _ReactionChip( + reactionId: displayEmoji, + count: reactionCounts[displayEmoji] ?? 0, + isSelected: myReactions.contains(displayEmoji), + tooltipNames: reactionUsers?[displayEmoji], + onTap: () => onToggleReaction?.call(displayEmoji!), + ); + } + + Widget _buildFullView() { + final sortedEntries = reactionCounts.entries.toList() + ..sort((a, b) => b.value.compareTo(a.value)); + return Container( - padding: const EdgeInsets.symmetric(vertical: 8), + padding: padding ?? const EdgeInsets.symmetric(vertical: 8), child: Wrap( spacing: 8, runSpacing: 8, - children: reactions.map((entry) { - final emoji = entry.key; - final count = entry.value; - final isMyReaction = myReactions.contains(emoji); - - return GestureDetector( - onTap: onReactionTap, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: isMyReaction - ? AppTheme.brightNavy.withValues(alpha: 0.15) - : AppTheme.navyBlue.withValues(alpha: 0.08), - borderRadius: BorderRadius.circular(16), - border: isMyReaction - ? Border.all(color: AppTheme.brightNavy.withValues(alpha: 0.3)) - : null, - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - emoji, - style: const TextStyle(fontSize: 14), - ), - const SizedBox(width: 4), - Text( - count > 99 ? '99+' : '$count', - style: GoogleFonts.inter( - color: isMyReaction ? AppTheme.brightNavy : AppTheme.textSecondary, - fontSize: 12, - fontWeight: FontWeight.w600, - ), - ), - ], - ), - ), - ); - }).toList(), + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + ...sortedEntries.map((entry) { + return _ReactionChip( + reactionId: entry.key, + count: entry.value, + isSelected: myReactions.contains(entry.key), + tooltipNames: reactionUsers?[entry.key], + onTap: () => onToggleReaction?.call(entry.key), + ); + }), + if (onAddReaction != null) + _ReactionAddButton(onTap: onAddReaction!), + ], ), ); } } + +class _ReactionChip extends StatefulWidget { + final String reactionId; + final int count; + final bool isSelected; + final List? tooltipNames; + final VoidCallback onTap; + + const _ReactionChip({ + required this.reactionId, + required this.count, + required this.isSelected, + required this.onTap, + this.tooltipNames, + }); + + @override + State<_ReactionChip> createState() => _ReactionChipState(); +} + +class _ReactionChipState extends State<_ReactionChip> { + int _tapCount = 0; + + void _handleTap() { + HapticFeedback.selectionClick(); + setState(() => _tapCount += 1); + widget.onTap(); + } + + @override + Widget build(BuildContext context) { + final isMyReaction = widget.isSelected; + + final chip = GestureDetector( + onTap: _handleTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: isMyReaction + ? AppTheme.brightNavy.withValues(alpha: 0.15) + : AppTheme.navyBlue.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(12), + border: isMyReaction + ? Border.all(color: AppTheme.brightNavy.withValues(alpha: 0.3)) + : null, + boxShadow: isMyReaction + ? [ + BoxShadow( + color: AppTheme.brightNavy.withValues(alpha: 0.1), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ] + : null, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _ReactionIcon(reactionId: widget.reactionId, size: 18), + if (widget.count > 0) ...[ + const SizedBox(width: 4), + Text( + widget.count > 99 ? '99+' : '${widget.count}', + style: GoogleFonts.inter( + color: isMyReaction ? AppTheme.brightNavy : AppTheme.textSecondary, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ], + ], + ), + ), + ) + .animate(key: ValueKey('tap_$_tapCount')) + .scale(begin: const Offset(1, 1), end: const Offset(1.1, 1.1), duration: 100.ms, curve: Curves.easeOut) + .then() + .scale(begin: const Offset(1.1, 1.1), end: const Offset(1, 1), duration: 150.ms, curve: Curves.easeOutBack); + + final names = widget.tooltipNames; + if (names == null || names.isEmpty) return chip; + + return Tooltip( + message: names.take(5).join(', '), + child: chip, + ); + } +} + +class _ReactionAddButton extends StatelessWidget { + final VoidCallback onTap; + + const _ReactionAddButton({required this.onTap}); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: AppTheme.navyBlue.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.add_reaction_outlined, + color: AppTheme.textSecondary, + size: 20, + ), + ], + ), + ), + ); + } +} + +class _ReactionIcon extends StatelessWidget { + final String reactionId; + final double size; + + const _ReactionIcon({required this.reactionId, this.size = 14}); + + @override + Widget build(BuildContext context) { + if (reactionId.startsWith('assets/') || reactionId.startsWith('asset:')) { + final assetPath = reactionId.startsWith('asset:') + ? reactionId.replaceFirst('asset:', '') + : reactionId; + + if (assetPath.endsWith('.svg')) { + return SvgPicture.asset( + assetPath, + width: size, + height: size, + ); + } + return Image.asset( + assetPath, + width: size, + height: size, + fit: BoxFit.contain, + ); + } + return Text( + reactionId, + style: TextStyle(fontSize: size), + ); + } +} diff --git a/sojorn_app/lib/widgets/reactions/smart_reaction_button.dart b/sojorn_app/lib/widgets/reactions/smart_reaction_button.dart index 7b656d5..71914b9 100644 --- a/sojorn_app/lib/widgets/reactions/smart_reaction_button.dart +++ b/sojorn_app/lib/widgets/reactions/smart_reaction_button.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:google_fonts/google_fonts.dart'; +import 'package:flutter_svg/flutter_svg.dart'; import '../../theme/app_theme.dart'; class SmartReactionButton extends ConsumerWidget { @@ -15,6 +16,32 @@ class SmartReactionButton extends ConsumerWidget { required this.onPressed, }); + Widget _buildReactionContent(String reaction) { + if (reaction.startsWith('assets/') || reaction.startsWith('asset:')) { + final assetPath = reaction.startsWith('asset:') + ? reaction.replaceFirst('asset:', '') + : reaction; + + if (assetPath.endsWith('.svg')) { + return SvgPicture.asset( + assetPath, + width: 18, + height: 18, + ); + } + return Image.asset( + assetPath, + width: 18, + height: 18, + fit: BoxFit.contain, + ); + } + return Text( + reaction, + style: const TextStyle(fontSize: 18), + ); + } + @override Widget build(BuildContext context, WidgetRef ref) { // Determine what to show @@ -28,10 +55,7 @@ class SmartReactionButton extends ConsumerWidget { icon: Row( mainAxisSize: MainAxisSize.min, children: [ - Text( - myReaction, - style: const TextStyle(fontSize: 18), - ), + _buildReactionContent(myReaction), const SizedBox(width: 4), Text( totalCount > 99 ? '99+' : '$totalCount', @@ -61,10 +85,7 @@ class SmartReactionButton extends ConsumerWidget { icon: Row( mainAxisSize: MainAxisSize.min, children: [ - Text( - topReaction.key, - style: const TextStyle(fontSize: 18), - ), + _buildReactionContent(topReaction.key), const SizedBox(width: 4), Text( totalCount > 99 ? '99+' : '$totalCount', diff --git a/sojorn_app/lib/widgets/sojorn_app_bar.dart b/sojorn_app/lib/widgets/sojorn_app_bar.dart index 1eb717e..58fac5c 100644 --- a/sojorn_app/lib/widgets/sojorn_app_bar.dart +++ b/sojorn_app/lib/widgets/sojorn_app_bar.dart @@ -36,11 +36,14 @@ class sojornAppBar extends StatelessWidget implements PreferredSizeWidget { onPressed: onBackPressed ?? () => Navigator.of(context).pop(), ) : null), - title: title != null - ? Text( - title!, + title: (title == null || title!.isEmpty) + ? Image.asset( + 'assets/images/toplogo.png', + height: 44, ) - : null, + : Text( + title!, + ), actions: actions, bottom: bottom, ); diff --git a/sojorn_app/pubspec.lock b/sojorn_app/pubspec.lock index 38b7c69..dc3b891 100644 --- a/sojorn_app/pubspec.lock +++ b/sojorn_app/pubspec.lock @@ -193,6 +193,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.6.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687" + url: "https://pub.dev" + source: hosted + version: "1.0.0" collection: dependency: transitive description: @@ -213,10 +221,10 @@ packages: dependency: transitive description: name: cross_file - sha256: "701dcfc06da0882883a2657c445103380e53e647060ad8d9dfb710c100996608" + sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937" url: "https://pub.dev" source: hosted - version: "0.3.5+1" + version: "0.3.5+2" crypto: dependency: transitive description: @@ -305,6 +313,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.4.1" + dio: + dependency: "direct main" + description: + name: dio + sha256: b9d46faecab38fc8cc286f80bc4d61a3bb5d4ac49e51ed877b4d6706efe57b25 + url: "https://pub.dev" + source: hosted + version: "5.9.1" + dio_web_adapter: + dependency: transitive + description: + name: dio_web_adapter + sha256: "7586e476d70caecaf1686d21eee7247ea43ef5c345eab9e0cc3583ff13378d78" + url: "https://pub.dev" + source: hosted + version: "2.1.1" emoji_picker_flutter: dependency: transitive description: @@ -325,10 +349,10 @@ packages: dependency: transitive description: name: ffi - sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418" + sha256: d07d37192dbf97461359c1518788f203b0c9102cfd2c35a716b823741219542c url: "https://pub.dev" source: hosted - version: "2.1.4" + version: "2.1.5" ffmpeg_kit_flutter_new: dependency: "direct main" description: @@ -829,6 +853,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.5" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" go_router: dependency: "direct main" description: @@ -869,6 +901,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" + hooks: + dependency: transitive + description: + name: hooks + sha256: "5d309c86e7ce34cd8e37aa71cb30cb652d3829b900ab145e4d9da564b31d59f7" + url: "https://pub.dev" + source: hosted + version: "1.0.0" html: dependency: transitive description: @@ -913,10 +953,10 @@ packages: dependency: transitive description: name: image_picker_android - sha256: "5e9bf126c37c117cf8094215373c6d561117a3cfb50ebc5add1a61dc6e224677" + sha256: "518a16108529fc18657a3e6dde4a043dc465d16596d20ab2abd49a4cac2e703d" url: "https://pub.dev" source: hosted - version: "0.8.13+10" + version: "0.8.13+13" image_picker_for_web: dependency: transitive description: @@ -929,10 +969,10 @@ packages: dependency: transitive description: name: image_picker_ios - sha256: "956c16a42c0c708f914021666ffcd8265dde36e673c9fa68c81f7d085d9774ad" + sha256: b9c4a438a9ff4f60808c9cf0039b93a42bb6c2211ef6ebb647394b2b3fa84588 url: "https://pub.dev" source: hosted - version: "0.8.13+3" + version: "0.8.13+6" image_picker_linux: dependency: transitive description: @@ -985,10 +1025,10 @@ packages: dependency: transitive description: name: json_annotation - sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" + sha256: "805fa86df56383000f640384b282ce0cb8431f1a7a2396de92fb66186d8c57df" url: "https://pub.dev" source: hosted - version: "4.9.0" + version: "4.10.0" latlong2: dependency: "direct main" description: @@ -1025,10 +1065,10 @@ packages: dependency: transitive description: name: lints - sha256: a5e2b223cb7c9c8efdc663ef484fdd95bb243bff242ef5b13e26883547fce9a0 + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" url: "https://pub.dev" source: hosted - version: "6.0.0" + version: "6.1.0" lists: dependency: transitive description: @@ -1141,6 +1181,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" + native_toolchain_c: + dependency: transitive + description: + name: native_toolchain_c + sha256: "89e83885ba09da5fdf2cdacc8002a712ca238c28b7f717910b34bcd27b0d03ac" + url: "https://pub.dev" + source: hosted + version: "0.17.4" nested: dependency: transitive description: @@ -1149,6 +1197,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: "983c7fa1501f6dcc0cb7af4e42072e9993cb28d73604d25ebf4dab08165d997e" + url: "https://pub.dev" + source: hosted + version: "9.2.5" octo_image: dependency: transitive description: @@ -1209,10 +1265,10 @@ packages: dependency: transitive description: name: path_provider_foundation - sha256: "6d13aece7b3f5c5a9731eaf553ff9dcbc2eff41087fd2df587fd0fed9a3eb0c4" + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" url: "https://pub.dev" source: hosted - version: "2.5.1" + version: "2.6.0" path_provider_linux: dependency: transitive description: @@ -1345,10 +1401,10 @@ packages: dependency: "direct main" description: name: pro_video_editor - sha256: "1b3229558edaf2b1c75d771a83c5ac8897d63d0dc9845f95de7b8428d5d6fbdf" + sha256: "18f62235212ff779a2ca967df4ce06cac22b7ff45051f46519754d94db2b04ff" url: "https://pub.dev" source: hosted - version: "1.3.0" + version: "1.4.1" proj4dart: dependency: transitive description: @@ -1365,6 +1421,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.1.5+1" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" quill_native_bridge: dependency: transitive description: @@ -1473,10 +1537,10 @@ packages: dependency: transitive description: name: shared_preferences_android - sha256: "83af5c682796c0f7719c2bbf74792d113e40ae97981b8f266fa84574573556bc" + sha256: cbc40be9be1c5af4dab4d6e0de4d5d3729e6f3d65b89d21e1815d57705644a6f url: "https://pub.dev" source: hosted - version: "2.4.18" + version: "2.4.20" shared_preferences_foundation: dependency: transitive description: @@ -1734,10 +1798,10 @@ packages: dependency: transitive description: name: url_launcher_web - sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2" + sha256: d0412fcf4c6b31ecfdb7762359b7206ffba3bbffd396c6d9f9c4616ece476c1f url: "https://pub.dev" source: hosted - version: "2.4.1" + version: "2.4.2" url_launcher_windows: dependency: transitive description: @@ -1830,10 +1894,10 @@ packages: dependency: transitive description: name: video_player_avfoundation - sha256: d1eb970495a76abb35e5fa93ee3c58bd76fb6839e2ddf2fbb636674f2b971dd4 + sha256: "7cc0a9257103851eb299a2407e895b0fd6832d323dcfde622a23cdc25a1de269" url: "https://pub.dev" source: hosted - version: "2.8.9" + version: "2.9.0" video_player_platform_interface: dependency: transitive description: @@ -1963,5 +2027,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.9.0 <4.0.0" - flutter: ">=3.35.0" + dart: ">=3.10.3 <4.0.0" + flutter: ">=3.38.4" diff --git a/sojorn_app/pubspec.yaml b/sojorn_app/pubspec.yaml index 929a54b..22f3204 100644 --- a/sojorn_app/pubspec.yaml +++ b/sojorn_app/pubspec.yaml @@ -1,5 +1,5 @@ name: sojorn -description: "sojorn - Friend's Only" +description: "Sojorn - Friend's Only. A product of MPLS LLC." publish_to: 'none' version: 1.0.0+1 @@ -20,6 +20,7 @@ dependencies: # HTTP & API http: ^1.2.2 + dio: ^5.7.0 # UI & Utilities cupertino_icons: ^1.0.8 @@ -96,6 +97,13 @@ flutter: - assets/images/applogo.png - assets/images/toplogo.png - assets/reactions/ + - assets/reactions/dotto/ + - assets/reactions/blue/ + - assets/reactions/green/ + - assets/reactions/purple/ + - assets/icon/ + - assets/rive/ + - assets/audio/ dependency_overrides: intl: 0.19.0 diff --git a/sojorn_app/web/index.html b/sojorn_app/web/index.html index a20e7a3..646b5c6 100644 --- a/sojorn_app/web/index.html +++ b/sojorn_app/web/index.html @@ -18,7 +18,7 @@ - + diff --git a/sojorn_app/web/manifest.json b/sojorn_app/web/manifest.json index f53ec84..33af137 100644 --- a/sojorn_app/web/manifest.json +++ b/sojorn_app/web/manifest.json @@ -5,7 +5,7 @@ "display": "standalone", "background_color": "#0175C2", "theme_color": "#0175C2", - "description": "A new Flutter project.", + "description": "Sojorn - Friend's Only. A product of MPLS LLC.", "orientation": "portrait-primary", "prefer_related_applications": false, "icons": [ @@ -32,4 +32,4 @@ "purpose": "maskable" } ] -} +} \ No newline at end of file diff --git a/sojorn_docs/E2EE_COMPREHENSIVE_GUIDE.md b/sojorn_docs/E2EE_COMPREHENSIVE_GUIDE.md index ddfc88e..75c25e8 100644 --- a/sojorn_docs/E2EE_COMPREHENSIVE_GUIDE.md +++ b/sojorn_docs/E2EE_COMPREHENSIVE_GUIDE.md @@ -142,6 +142,7 @@ encrypted_messages (ciphertext + header + metadata) - ✅ OTK management (generation, usage, deletion) - ✅ Backend key storage/retrieval - ✅ Cross-platform encryption (Android↔Web) +- ✅ **Full Backup & Recovery** (Keys + Messages) ### Key Files Modified ``` @@ -188,63 +189,56 @@ Receiver: [DECRYPT] SUCCESS: Decrypted message: "[message_text]" ``` -## Next Steps: Message Recovery +## Backup & Recovery System ✅ -### Problem -When users uninstall the app or lose local keys, they cannot decrypt historical messages. +### Overview +A robust local backup and recovery system has been implemented to address the risk of data loss on device changes or app uninstalls. This system allows users to export their cryptographic identity and message history into a secure, portable file. -### Solution Requirements -1. **Key Backup Strategy**: Securely backup encryption keys -2. **Message Recovery**: Allow decryption of historical messages after key recovery -3. **Security**: Maintain E2EE guarantees while enabling recovery +### Architecture -### Proposed Solutions +#### 1. Security Model +- **Encryption**: AES-256-GCM +- **Key Derivation**: Argon2id (from user password) +- **Storage**: Local file system (portable JSON file) +- **Trust**: Zero-knowledge (server never sees the backup file or password) -#### Option 1: Cloud Key Backup (Recommended) -- Encrypt identity keys with user password -- Store encrypted backup in cloud storage -- Recover keys with password authentication +#### 2. Backup Content +The encrypted backup file contains two main components: +1. **Key Material**: + * Identity Key Pair (Ed25519 & X25519) + * Signed PreKey Pair (with signature) + * One-Time PreKeys (all unused keys) +2. **Message History** (Optional): + * Full plaintext message history + * Metadata (sender, timestamp, etc.) + * *Note: Messages are decrypted from local storage and re-encrypted with the backup password for portability.* -**Pros**: -- Most user-friendly -- Maintains security (password-protected) -- Technically straightforward -- Reversible if needed +#### 3. Backup Flow +1. **User Initiation**: User selects "Full Backup & Recovery" in settings. +2. **Password Entry**: User sets a strong backup password. +3. **Data Gathering**: + * `SimpleE2EEService` exports all key pairs. + * (Optional) `LocalMessageStore` exports all message records. +4. **Encryption**: + * Salt & Nonce generated. + * Key derived from password via Argon2id. + * Payload (keys + messages) encrypted via AES-GCM. +5. **File Generation**: JSON file containing ciphertext, salt, nonce, and metadata is saved to device. -#### Option 2: Social Recovery -- Allow trusted contacts to help recover keys -- Use Shamir's Secret Sharing for security -- Requires multiple trusted contacts +#### 4. Restore Flow +1. **File Selection**: User selects the `.json` backup file. +2. **Decryption**: + * User enters password. + * Key derived using stored salt. + * Payload decrypted. +3. **Import**: + * Keys are imported into `SimpleE2EEService` and persisted to secure storage. + * Messages are imported into `LocalMessageStore` and re-encrypted with the device's *new* local storage key. -#### Option 3: Server-Side Recovery (Limited) -- Store encrypted key backups on server -- Server cannot decrypt without user password -- Similar to Signal's approach - -#### Option 4: Message Re-encryption -- Store messages encrypted with server keys -- Re-encrypt with new keys after recovery -- Breaks perfect forward secrecy - -### Implementation Plan for Key Recovery - -#### Phase 1: Key Backup -1. Add password-based key encryption -2. Implement cloud backup storage -3. Add backup/restore UI -4. Test backup/restore flow - -#### Phase 2: Message Recovery -1. Store message headers for re-decryption -2. Implement batch message re-decryption -3. Add recovery progress indicators -4. Test with historical messages - -#### Phase 3: Security Enhancements -1. Add backup encryption verification -2. Implement backup rotation -3. Add recovery security checks -4. Monitor recovery success rates +### Technical Implementation +* **Service**: `LocalKeyBackupService` handles the encryption/decryption pipeline. +* **Store**: `LocalMessageStore` provides bulk export/import methods (`getAllMessageRecords`, `saveMessageRecord`). +* **UI**: `LocalBackupScreen` provides the interface for creating and restoring backups. ## Security Considerations @@ -287,10 +281,10 @@ The E2EE implementation is now fully functional with all major issues resolved. - Automatic key management - Secure message transmission -The next phase focuses on key recovery to handle user device changes while maintaining security principles. +The next phase focuses on device management to handle users with multiple active devices simultaneously. --- -**Last Updated**: January 30, 2026 -**Status**: ✅ Production Ready (except key recovery) -**Next Priority**: Implement key recovery system +**Last Updated**: February 2, 2026 +**Status**: ✅ Production Ready (including key/message recovery) +**Next Priority**: Device Management (Multi-device support) diff --git a/sojorn_docs/SOJORN_ARCHITECTURE.md b/sojorn_docs/SOJORN_ARCHITECTURE.md new file mode 100644 index 0000000..d544297 --- /dev/null +++ b/sojorn_docs/SOJORN_ARCHITECTURE.md @@ -0,0 +1,395 @@ +# Sojorn Architecture & Deployment Guide + +> **Last Updated:** 2026-02-03 +> **Maintainer:** MPLS LLC + +--- + +## Table of Contents +1. [Overview](#overview) +2. [Project Structure](#project-structure) +3. [Flutter App](#flutter-app) +4. [Go Backend](#go-backend) +5. [Server Deployment](#server-deployment) +6. [Database](#database) +7. [Common Commands](#common-commands) +8. [Troubleshooting](#troubleshooting) + +--- + +## Overview + +**Sojorn** is a social media platform with the following core features: +- **Posts & Feeds:** Category-based content discovery +- **Chains (Threads):** Reddit-style threaded conversations +- **Beacons:** Location-based ephemeral posts +- **E2EE Chat:** End-to-end encrypted direct messaging +- **Profiles:** User profiles with follow/friend system +- **Reactions:** Custom emoji reactions on posts + +### Tech Stack +| Component | Technology | +|-----------|------------| +| Mobile/Web App | Flutter (Dart) | +| Backend API | Go (Gin framework) | +| Database | PostgreSQL | +| Media Storage | Cloudflare R2 | +| Realtime | WebSockets | + +--- + +## Project Structure + +``` +C:\Webs\Sojorn\ +├── sojorn_app/ # Flutter application +│ ├── lib/ # Dart source code +│ │ ├── screens/ # UI screens +│ │ ├── widgets/ # Reusable widgets +│ │ ├── services/ # Business logic & API calls +│ │ ├── models/ # Data models +│ │ ├── providers/ # Riverpod state management +│ │ └── theme/ # App theming +│ ├── web/ # Web-specific assets +│ ├── android/ # Android configs +│ ├── ios/ # iOS configs +│ └── pubspec.yaml # Dependencies +│ +├── go-backend/ # Go API server +│ ├── cmd/api/ # Main entry point (main.go) +│ ├── internal/ +│ │ ├── handlers/ # HTTP route handlers +│ │ ├── repository/ # Database queries +│ │ ├── services/ # Business logic +│ │ ├── models/ # Go structs +│ │ ├── middleware/ # Auth, CORS, rate limiting +│ │ └── config/ # Environment configuration +│ └── pkg/utils/ # Shared utilities +│ +└── sojorn_docs/ # Documentation +``` + +--- + +## Flutter App + +### Running Locally + +```powershell +cd C:\Webs\Sojorn\sojorn_app + +# Web (Chrome) +flutter run -d chrome + +# Android +flutter run -d + +# iOS +flutter run -d +``` + +### API Configuration +The app connects to the production API at: +``` +https://api.gosojorn.com (or http://194.238.28.122:8080) +``` + +Configuration is in: `lib/config/api_config.dart` + +### Key Files +| File | Purpose | +|------|---------| +| `lib/services/api_service.dart` | All backend API calls | +| `lib/services/auth_service.dart` | Authentication & token management | +| `lib/services/secure_chat_service.dart` | E2EE messaging | +| `lib/screens/profile/profile_screen.dart` | User's own profile | +| `lib/screens/profile/viewable_profile_screen.dart` | Viewing other profiles | + +--- + +## Go Backend + +### Local Development + +```powershell +cd C:\Webs\Sojorn\go-backend + +# Run locally +go run ./cmd/api + +# Build binary +go build -o sojorn-api ./cmd/api +``` + +### Environment Variables +Create `.env` file or set environment variables: +```env +DATABASE_URL=postgres://user:pass@localhost:5432/sojorn?sslmode=disable +PORT=8080 +JWT_SECRET=your-secret +CORS_ORIGINS=* +R2_ACCESS_KEY=... +R2_SECRET_KEY=... +``` + +### Key Files +| File | Purpose | +|------|---------| +| `cmd/api/main.go` | Server setup & route registration | +| `internal/handlers/post_handler.go` | Post CRUD & feed endpoints | +| `internal/handlers/user_handler.go` | Profile & social endpoints | +| `internal/repository/post_repository.go` | Post database queries | +| `internal/repository/user_repository.go` | User database queries | + +--- + +## Server Deployment + +### Server Details +| Property | Value | +|----------|-------| +| **IP Address** | `194.238.28.122` | +| **SSH User** | `patrick` | +| **SSH Key** | `C:\Users\Patrick\.ssh\mpls.pem` | +| **Sudo Password** | `P22k154ever!` | + +### Directory Structure on Server +``` +/opt/sojorn/ +├── bin/ +│ └── api # ⚠️ THE RUNNING BINARY +├── go-backend/ # Git repo clone +│ └── ... +├── .env # Environment variables +└── sojorn-api # Build output (copy to bin/api) +``` + +### Systemd Service +**Service Name:** `sojorn-api` +**Config File:** `/etc/systemd/system/sojorn-api.service` + +```ini +[Unit] +Description=Sojorn Golang API Server +After=network.target postgresql.service + +[Service] +Type=simple +User=root +WorkingDirectory=/opt/sojorn +ExecStart=/opt/sojorn/bin/api # ⚠️ IMPORTANT: Uses bin/api +Restart=always +RestartSec=5s +EnvironmentFile=/opt/sojorn/.env + +[Install] +WantedBy=multi-user.target +``` + +### Deployment Workflow + +```bash +# 1. SSH to server +ssh -i "C:\Users\Patrick\.ssh\mpls.pem" patrick@194.238.28.122 + +# 2. Pull latest code +cd /opt/sojorn/go-backend +git fetch origin +git reset --hard origin/ThreadRestoration # or main branch + +# 3. Build +go build -o sojorn-api ./cmd/api + +# 4. Stop service, copy binary, start service +sudo systemctl stop sojorn-api +sudo cp sojorn-api /opt/sojorn/bin/api +sudo systemctl start sojorn-api + +# 5. Verify +sudo systemctl status sojorn-api +``` + +### Quick Deploy Command (from Windows) +```powershell +ssh -i "C:\Users\Patrick\.ssh\mpls.pem" patrick@194.238.28.122 "cd /opt/sojorn/go-backend && git fetch origin && git reset --hard origin/ThreadRestoration && go build -o sojorn-api ./cmd/api && echo 'P22k154ever!' | sudo -S systemctl stop sojorn-api && echo 'P22k154ever!' | sudo -S cp sojorn-api /opt/sojorn/bin/api && echo 'P22k154ever!' | sudo -S systemctl start sojorn-api" +``` + +--- + +## Database + +### Connection Details +| Property | Value | +|----------|-------| +| **Host** | `localhost` (from server) | +| **Port** | `5432` | +| **Database** | `sojorn` | +| **User** | `postgres` | +| **Password** | `A24Zr7AEoch4eO0N` | + +### Connection String +``` +postgres://postgres:A24Zr7AEoch4eO0N@localhost:5432/sojorn?sslmode=disable +``` + +### Key Tables +| Table | Purpose | +|-------|---------| +| `profiles` | User profiles | +| `posts` | All posts (regular, chains, beacons) | +| `post_metrics` | Like/comment counts | +| `post_likes` | Who liked which post | +| `post_saves` | Saved/bookmarked posts | +| `post_reactions` | Emoji reactions | +| `follows` | Follow relationships | +| `conversations` | E2EE chat conversations | +| `messages` | Chat messages | +| `categories` | Content categories | +| `category_settings` | User category preferences | + +### Useful Queries +```sql +-- Check saved posts +SELECT COUNT(*) FROM post_saves; + +-- Check user's saved posts +SELECT * FROM post_saves WHERE user_id = 'uuid-here'; + +-- Check posts table +SELECT id, author_id, body, created_at FROM posts LIMIT 10; +``` + +--- + +## Common Commands + +### Flutter App +```powershell +# Run on Chrome +flutter run -d chrome + +# Build web +flutter build web + +# Run tests +flutter test + +# Analyze code +flutter analyze +``` + +### Go Backend +```powershell +# Run locally +cd C:\Webs\Sojorn\go-backend +go run ./cmd/api + +# Build +go build -o sojorn-api ./cmd/api + +# Format code +go fmt ./... +``` + +### Server Management +```bash +# SSH to server +ssh -i "C:\Users\Patrick\.ssh\mpls.pem" patrick@194.238.28.122 + +# Service commands +sudo systemctl status sojorn-api +sudo systemctl restart sojorn-api +sudo systemctl stop sojorn-api +sudo systemctl start sojorn-api + +# View logs +sudo journalctl -u sojorn-api -f + +# Database access +PGPASSWORD=A24Zr7AEoch4eO0N psql -h localhost -U postgres -d sojorn +``` + +### Git Operations +```powershell +# Commit and push (from local) +cd C:\Webs\Sojorn\go-backend +git add . +git commit -m "Your message" +git push + +# Pull on server +ssh ... "cd /opt/sojorn/go-backend && git pull" +``` + +--- + +## Troubleshooting + +### Route Not Found (404) After Deployment +**Cause:** Old binary is still running. +**Solution:** Stop service before copying new binary: +```bash +sudo systemctl stop sojorn-api +sudo cp sojorn-api /opt/sojorn/bin/api +sudo systemctl start sojorn-api +``` + +### "Text file busy" Error +**Cause:** Trying to overwrite running binary. +**Solution:** Stop the service first. + +### Server Not Responding +1. Check service status: `sudo systemctl status sojorn-api` +2. Check logs: `sudo journalctl -u sojorn-api -n 50` +3. Verify port: `sudo netstat -tlnp | grep 8080` + +### Database Connection Errors +1. Check PostgreSQL: `sudo systemctl status postgresql` +2. Verify credentials in `/opt/sojorn/.env` +3. Test connection: `psql -h localhost -U postgres -d sojorn` + +### Flutter Build Errors +1. Clean build: `flutter clean && flutter pub get` +2. Analyze: `flutter analyze` +3. Check Dart version: `flutter doctor` + +--- + +## API Endpoints Reference + +### Authentication +| Method | Endpoint | Description | +|--------|----------|-------------| +| POST | `/api/v1/auth/register` | Register new user | +| POST | `/api/v1/auth/login` | Login | +| POST | `/api/v1/auth/refresh` | Refresh token | + +### Profile +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/v1/profile` | Get own profile | +| GET | `/api/v1/profiles/:id` | Get profile by ID | +| PATCH | `/api/v1/profile` | Update profile | + +### Posts +| Method | Endpoint | Description | +|--------|----------|-------------| +| POST | `/api/v1/posts` | Create post | +| GET | `/api/v1/posts/:id` | Get single post | +| GET | `/api/v1/feed` | Get user's feed | +| GET | `/api/v1/users/:id/posts` | Get user's posts | +| GET | `/api/v1/users/:id/saved` | Get user's saved posts | +| POST | `/api/v1/posts/:id/save` | Save a post | +| DELETE | `/api/v1/posts/:id/save` | Unsave a post | + +### Social +| Method | Endpoint | Description | +|--------|----------|-------------| +| POST | `/api/v1/users/:id/follow` | Follow user | +| DELETE | `/api/v1/users/:id/follow` | Unfollow user | +| POST | `/api/v1/posts/:id/like` | Like post | +| DELETE | `/api/v1/posts/:id/like` | Unlike post | + +--- + +*This document should be updated whenever deployment procedures or architecture changes.* diff --git a/zero_knowledge_backup_summary.md b/zero_knowledge_backup_summary.md new file mode 100644 index 0000000..91fc2a0 --- /dev/null +++ b/zero_knowledge_backup_summary.md @@ -0,0 +1,36 @@ +# Zero Knowledge Cloud Backup - Implementation Summary + +## Status: COMPLETE & DEPLOYED ✅ + +### 1. Security Architecture (Zero Knowledge) +- **Principle**: The server never sees your private keys. +- **Cloud Backups**: Default to **Messages Only**. The payload contains encrypted message history but explicitly excludes the key pairs needed to decrypt them. +- **Local Backups**: Must be used to backup **Keys + Messages**. This is the only way to restore your identity (Subjective Identity). +- **Encryption**: All data is encrypted client-side using Argon2id (password derivation) and AES-GCM (content encryption) before leaving the device. + +### 2. Frontend Implementation (Flutter) +- **Service**: Rebuilt `LocalKeyBackupService.dart` to handle the dual-mode backup logic. +- **UI**: + - Added toggle for "Cloud" vs "Local" backup. + - "Secure Mode" indicator when uploading to cloud (confirming keys are excluded). + - Explicit warning: "NOT RECOMMENDED for cloud backups. Keep keys local." + +### 3. Backend Implementation (Go) +- **Endpoints**: `POST /backups/upload` and `GET /backups/download` are active. +- **Storage**: Stores opaque `encrypted_blob`, `salt`, `nonce`, and `mac`. +- **Database**: + - Applied migration `000003_e2ee_backup_recovery`. + - Created tables: `cloud_backups`, `backup_preferences`, `user_devices`. + +### 4. Deployment Details +- **Server**: `194.238.28.122` +- **Service**: `sojorn-api` (Restarted at ~01:55 server time) +- **Database**: Migrations applied successfully via `migrate-linux` tool. + +### 5. How to Test +1. **Create Identity Backup**: Go to "Full Backup & Recovery" -> Select "Local File" -> "Export Backup". Save this file safely! +2. **Cloud Upload**: Switch to "Cloud Backup" -> Encrypt with password -> "Upload Backup". +3. **Restore Flow**: + - Wipe app / New Device. + - **Step 1**: "Import Backup" using your Local File (Restores Identity/Keys). + - **Step 2**: "Download & Restore" from Cloud (Restores Message History).