diff --git a/deploy_all_functions.bak.ps1 b/deploy_all_functions.bak.ps1 deleted file mode 100644 index f153cbc..0000000 --- a/deploy_all_functions.bak.ps1 +++ /dev/null @@ -1,68 +0,0 @@ -# Deploy all Edge Functions to Supabase -# Run this after updating supabase-js version - -Write-Host "=== Deploying All Edge Functions ===" -ForegroundColor Cyan -Write-Host "" -Write-Host "This will deploy all functions with --no-verify-jwt (default for this script)" -ForegroundColor Yellow -Write-Host "" - -$functions = @( - "appreciate", - "block", - "calculate-harmony", - "cleanup-expired-content", - "consume_one_time_prekey", - "create-beacon", - "deactivate-account", - "delete-account", - "e2ee_session_manager", - "feed-personal", - "feed-sojorn", - "follow", - "manage-post", - "notifications", - "profile", - "profile-posts", - "public-config", - "publish-comment", - "publish-post", - "push-notification", - "report", - "save", - "search", - "sign-media", - "signup", - "tone-check", - "trending", - "upload-image" -) - -$totalFunctions = $functions.Count -$currentFunction = 0 -$noVerifyJwt = "--no-verify-jwt" - -foreach ($func in $functions) { - $currentFunction++ - Write-Host "[$currentFunction/$totalFunctions] Deploying $func..." -ForegroundColor Yellow - - try { - supabase functions deploy $func $noVerifyJwt 2>&1 | Out-Null - if ($LASTEXITCODE -eq 0) { - Write-Host " OK $func deployed successfully" -ForegroundColor Green - } else { - Write-Host " FAILED to deploy $func" -ForegroundColor Red - } - } - catch { - Write-Host " ERROR deploying $func : $_" -ForegroundColor Red - } -} - -Write-Host "" -Write-Host "=== Deployment Complete ===" -ForegroundColor Cyan -Write-Host "" -Write-Host "Next steps:" -ForegroundColor Yellow -Write-Host "1. Restart your Flutter app" -ForegroundColor Yellow -Write-Host "2. Sign in again" -ForegroundColor Yellow -Write-Host "3. The JWT 401 errors should be gone!" -ForegroundColor Green -Write-Host "" diff --git a/go-backend/cmd/inspect/main_draft.go b/go-backend/cmd/inspect/main_draft.go deleted file mode 100644 index 9b94e18..0000000 --- a/go-backend/cmd/inspect/main_draft.go +++ /dev/null @@ -1,23 +0,0 @@ -package main - -import ( - "os" - - "github.com/joho/godotenv" -) - -func main() { - // Load .env manualy since config might rely on it - godotenv.Load() - - dbURL := os.Getenv("DATABASE_URL") - if dbURL == "" { - // Fallback for local dev if .env not loaded or empty - dbURL = "postgresql://postgres:password@localhost:5432/sojorn_db" // Guessing name? - // Wait, user used 'postgres' db in psql command attempts. - // Let's assume standard postgres connection string. - // The psql command failed so I don't know the DB Name. - // But main.go loads config. Let's rely on that if possible, but importing main's config might be circular or complex if not in a lib. - // I'll try to use the one from config.LoadConfig() by importing github.com/patbritton/sojorn-backend/internal/config - } -} diff --git a/go-backend/internal/config/config.go b/go-backend/internal/config/config.go index 0de8ca6..eee9b54 100644 --- a/go-backend/internal/config/config.go +++ b/go-backend/internal/config/config.go @@ -36,6 +36,8 @@ type Config struct { R2MediaBucket string R2VideoBucket string TurnstileSecretKey string + APIBaseURL string + AppBaseURL string } func LoadConfig() *Config { @@ -78,6 +80,8 @@ func LoadConfig() *Config { R2MediaBucket: getEnv("R2_MEDIA_BUCKET", "sojorn-media"), R2VideoBucket: getEnv("R2_VIDEO_BUCKET", "sojorn-videos"), TurnstileSecretKey: getEnv("TURNSTILE_SECRET", ""), + APIBaseURL: getEnv("API_BASE_URL", "https://api.sojorn.net"), + AppBaseURL: getEnv("APP_BASE_URL", "https://sojorn.net"), } } diff --git a/go-backend/internal/handlers/auth_handler.go b/go-backend/internal/handlers/auth_handler.go index f2b643c..95addc5 100644 --- a/go-backend/internal/handlers/auth_handler.go +++ b/go-backend/internal/handlers/auth_handler.go @@ -45,8 +45,9 @@ type RegisterRequest struct { } type LoginRequest struct { - Email string `json:"email" binding:"required,email"` - Password string `json:"password" binding:"required"` + Email string `json:"email" binding:"required,email"` + Password string `json:"password" binding:"required"` + TurnstileToken string `json:"turnstile_token" binding:"required"` } func (h *AuthHandler) Register(c *gin.Context) { @@ -160,6 +161,23 @@ func (h *AuthHandler) Login(c *gin.Context) { } req.Email = strings.ToLower(strings.TrimSpace(req.Email)) + // Validate Turnstile token + turnstileService := services.NewTurnstileService(h.config.TurnstileSecretKey) + remoteIP := c.ClientIP() + turnstileResp, err := turnstileService.VerifyToken(req.TurnstileToken, remoteIP) + if err != nil { + log.Printf("[Auth] Login Turnstile verification failed: %v", err) + c.JSON(http.StatusBadRequest, gin.H{"error": "Security verification failed"}) + return + } + + if !turnstileResp.Success { + errorMsg := turnstileService.GetErrorMessage(turnstileResp.ErrorCodes) + log.Printf("[Auth] Login Turnstile validation failed: %s", errorMsg) + c.JSON(http.StatusBadRequest, gin.H{"error": errorMsg}) + return + } + user, err := h.repo.GetUserByEmail(c.Request.Context(), req.Email) if err != nil { log.Printf("[Auth] Login failed for %s: user not found", req.Email) @@ -234,7 +252,7 @@ func (h *AuthHandler) CompleteOnboarding(c *gin.Context) { func (h *AuthHandler) VerifyEmail(c *gin.Context) { rawToken := c.Query("token") if rawToken == "" { - c.Redirect(http.StatusFound, "https://sojorn.net/verify-error?reason=invalid_token") + c.Redirect(http.StatusFound, h.config.AppBaseURL+"/verify-error?reason=invalid_token") return } @@ -243,19 +261,19 @@ func (h *AuthHandler) VerifyEmail(c *gin.Context) { userID, expiresAt, err := h.repo.GetVerificationToken(c.Request.Context(), hashString) if err != nil { - c.Redirect(http.StatusFound, "https://sojorn.net/verify-error?reason=invalid_token") + c.Redirect(http.StatusFound, h.config.AppBaseURL+"/verify-error?reason=invalid_token") return } if time.Now().After(expiresAt) { h.repo.DeleteVerificationToken(c.Request.Context(), hashString) - c.Redirect(http.StatusFound, "https://sojorn.net/verify-error?reason=expired") + c.Redirect(http.StatusFound, h.config.AppBaseURL+"/verify-error?reason=expired") return } // Activate user if err := h.repo.UpdateUserStatus(c.Request.Context(), userID, models.UserStatusActive); err != nil { - c.Redirect(http.StatusFound, "https://sojorn.net/verify-error?reason=server_error") + c.Redirect(http.StatusFound, h.config.AppBaseURL+"/verify-error?reason=server_error") return } @@ -275,7 +293,7 @@ func (h *AuthHandler) VerifyEmail(c *gin.Context) { // Cleanup _ = h.repo.DeleteVerificationToken(c.Request.Context(), hashString) - c.Redirect(http.StatusFound, "https://sojorn.net/verified") + c.Redirect(http.StatusFound, h.config.AppBaseURL+"/verified?status=success") } func (h *AuthHandler) ResendVerificationEmail(c *gin.Context) { diff --git a/go-backend/internal/repository/user_repository.go b/go-backend/internal/repository/user_repository.go index 20effbd..4498813 100644 --- a/go-backend/internal/repository/user_repository.go +++ b/go-backend/internal/repository/user_repository.go @@ -559,7 +559,7 @@ func (r *UserRepository) DeleteUsedOTK(ctx context.Context, userID string, keyID if err != nil { return fmt.Errorf("failed to delete used OTK: %w", err) } - fmt.Printf("[KEYS] Deleted used OTK #%d for user %s\n", keyID, userID) + // OTK deleted successfully return nil } @@ -613,9 +613,7 @@ func (r *UserRepository) GetSignalKeyBundle(ctx context.Context, userID string) "key_id": otkID, "public_key": otkPub, } - fmt.Printf("[KEYS] Retrieved OTK #%d for user %s\n", otkID, userID) - } else { - fmt.Printf("[KEYS] No OTKs available for user %s\n", userID) + // OTK retrieved - not logging user ID for security } // Handle NULL values properly diff --git a/go-backend/internal/services/email_service.go b/go-backend/internal/services/email_service.go index c5370ff..5c5d5e9 100644 --- a/go-backend/internal/services/email_service.go +++ b/go-backend/internal/services/email_service.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "net/http" + "strings" "sync" "time" @@ -51,65 +52,55 @@ type sendPulseIdentity struct { func (s *EmailService) SendVerificationEmail(toEmail, toName, token string) error { subject := "Verify your Sojorn account" - verifyURL := fmt.Sprintf("https://api.sojorn.net/api/v1/auth/verify?token=%s", token) + // Ensure we don't double up on /api/v1 if it's already in the config + apiBase := strings.TrimSuffix(s.config.APIBaseURL, "/api/v1") + verifyURL := fmt.Sprintf("%s/api/v1/auth/verify?token=%s", apiBase, token) - body := fmt.Sprintf(` - - - - - - -
-
-
- -
-
-

Welcome to Sojorn, %s

-

Thanks for signing up! To get started, please verify your email address by clicking the button below.

- Verify Email Address -

If the button doesn't work, copy and paste this link into your browser:

- %s -
- -
-
- - - `, toName, verifyURL, verifyURL, verifyURL) + title := "Email Verification" + header := fmt.Sprintf("Hey %s! šŸ‘‹", toName) + if toName == "" { + header = "Hey there! šŸ‘‹" + } - return s.sendEmail(toEmail, toName, subject, body, "Verify your Sojorn account: "+verifyURL) + content := ` +

Welcome to Sojorn — your vibrant new social space. We're thrilled to have you join our community!

+

To get started in the app, please verify your email address by clicking the button below:

+ ` + + footer := ` +
+

If the button doesn't work, copy and paste this link into your browser:

+ %s +
+ ` + footer = fmt.Sprintf(footer, verifyURL, verifyURL) + + htmlBody := s.buildHTMLEmail(title, header, content, verifyURL, "Verify My Email", footer) + + textBody := fmt.Sprintf("Welcome to Sojorn! Please verify your email by clicking here: %s", verifyURL) + + return s.sendEmail(toEmail, toName, subject, htmlBody, textBody) } func (s *EmailService) SendPasswordResetEmail(toEmail, toName, token string) error { subject := "Reset your Sojorn password" - resetURL := fmt.Sprintf("https://sojorn.net/reset-password?token=%s", token) + resetURL := fmt.Sprintf("%s/reset-password?token=%s", s.config.AppBaseURL, token) - body := fmt.Sprintf(` -

Reset Password for %s

-

You requested a password reset. Click the link below to set a new password:

-

Reset Password

-

This link expires in 1 hour.

-

If you did not request this, please ignore this email.

- `, toName, resetURL) + title := "Password Reset" + header := "Reset your password" + content := fmt.Sprintf(` +

Hey %s,

+

You requested a password reset for your Sojorn account. Click the button below to set a new password:

+ `, toName) - return s.sendEmail(toEmail, toName, subject, body, "Reset your password: "+resetURL) + footer := ` +

This link expires in 1 hour. If you did not request this, you can safely ignore this email.

+ ` + + htmlBody := s.buildHTMLEmail(title, header, content, resetURL, "Reset Password", footer) + textBody := fmt.Sprintf("Reset your Sojorn password: %s", resetURL) + + return s.sendEmail(toEmail, toName, subject, htmlBody, textBody) } func (s *EmailService) sendEmail(toEmail, toName, subject, htmlBody, textBody string) error { @@ -137,7 +128,15 @@ func (s *EmailService) sendEmail(toEmail, toName, subject, htmlBody, textBody st s.config.SMTPPort, ) - err := emailSender.SendPlainEmail([]string{toEmail}, nil, subject, htmlBody, nil) + // SMTP Fallback - Send HTML email + err := emailSender.SendHTMLEmail( + "Sojorn", // from name + []string{toEmail}, // recipients + nil, // cc + subject, // subject + htmlBody, // html body + nil, // attachments + ) if err != nil { log.Error().Err(err).Msg("Failed to send email via SMTP") return err @@ -256,3 +255,51 @@ func (s *EmailService) AddSubscriber(email, name string) { // SendPulse Addressbook API implementation omitted for brevity, focusing on email first // Endpoint: POST /addressbooks/{id}/emails } + +func (s *EmailService) buildHTMLEmail(title, header, content, buttonURL, buttonText, footer string) string { + return fmt.Sprintf(` + + + + + + %s + + +
+
+ +
+ Sojorn +
%s
+
+ + +
+

%s

+
+ %s +
+ + + %s + + + %s +
+ + +
+

Ā© 2026 Sojorn by MPLS LLC. All rights reserved.

+
+ Website • + Privacy • + Terms +
+
+
+
+ + + `, title, title, header, content, buttonURL, buttonText, footer) +} diff --git a/run_dev.ps1 b/run_dev.ps1 index ffb9f93..706edad 100644 --- a/run_dev.ps1 +++ b/run_dev.ps1 @@ -42,10 +42,7 @@ $defineArgs = @( $optionalDefines = @( 'FIREBASE_WEB_VAPID_KEY', - 'SUPABASE_PUBLISHABLE_KEY', - 'SUPABASE_SECRET_KEY', - 'SUPABASE_JWT_KID', - 'SUPABASE_JWKS_URI' + 'TURNSTILE_SITE_KEY' ) foreach ($opt in $optionalDefines) { @@ -57,6 +54,7 @@ foreach ($opt in $optionalDefines) { Push-Location (Join-Path $PSScriptRoot "sojorn_app") try { flutter run @defineArgs @Args -} finally { +} +finally { Pop-Location } diff --git a/run_web.ps1 b/run_web.ps1 index 7bda38d..e0e4960 100644 --- a/run_web.ps1 +++ b/run_web.ps1 @@ -32,7 +32,7 @@ $values = Parse-Env $EnvPath # Collect dart-defines we actually use on web. $defineArgs = @() -$keysOfInterest = @('API_BASE_URL', 'FIREBASE_WEB_VAPID_KEY') +$keysOfInterest = @('API_BASE_URL', 'FIREBASE_WEB_VAPID_KEY', 'TURNSTILE_SITE_KEY') foreach ($k in $keysOfInterest) { if ($values.ContainsKey($k) -and -not [string]::IsNullOrWhiteSpace($values[$k])) { $defineArgs += "--dart-define=$k=$($values[$k])" diff --git a/run_web_chrome.ps1 b/run_web_chrome.ps1 index f71318e..9a31a5a 100644 --- a/run_web_chrome.ps1 +++ b/run_web_chrome.ps1 @@ -32,7 +32,7 @@ $values = Parse-Env $EnvPath # Collect dart-defines we actually use on web. $defineArgs = @() -$keysOfInterest = @('API_BASE_URL', 'FIREBASE_WEB_VAPID_KEY') +$keysOfInterest = @('API_BASE_URL', 'FIREBASE_WEB_VAPID_KEY', 'TURNSTILE_SITE_KEY') foreach ($k in $keysOfInterest) { if ($values.ContainsKey($k) -and -not [string]::IsNullOrWhiteSpace($values[$k])) { $defineArgs += "--dart-define=$k=$($values[$k])" diff --git a/run_windows.ps1 b/run_windows.ps1 index 492fd4b..4f655fa 100644 --- a/run_windows.ps1 +++ b/run_windows.ps1 @@ -31,7 +31,7 @@ $values = Parse-Env $EnvPath # Collect dart-defines we actually use on Windows. $defineArgs = @() -$keysOfInterest = @('API_BASE_URL', 'FIREBASE_WEB_VAPID_KEY') +$keysOfInterest = @('API_BASE_URL', 'FIREBASE_WEB_VAPID_KEY', 'TURNSTILE_SITE_KEY') foreach ($k in $keysOfInterest) { if ($values.ContainsKey($k) -and -not [string]::IsNullOrWhiteSpace($values[$k])) { $defineArgs += "--dart-define=$k=$($values[$k])" diff --git a/sojorn_app/assets/images/mplslarge.png b/sojorn_app/assets/images/mplslarge.png new file mode 100644 index 0000000..7fa2d5d Binary files /dev/null and b/sojorn_app/assets/images/mplslarge.png differ diff --git a/sojorn_app/assets/images/mplsmedium.png b/sojorn_app/assets/images/mplsmedium.png new file mode 100644 index 0000000..d55e6c5 Binary files /dev/null and b/sojorn_app/assets/images/mplsmedium.png differ diff --git a/sojorn_app/assets/images/mplssmall.png b/sojorn_app/assets/images/mplssmall.png new file mode 100644 index 0000000..cfc3b76 Binary files /dev/null and b/sojorn_app/assets/images/mplssmall.png differ diff --git a/sojorn_app/lib/models/post.dart b/sojorn_app/lib/models/post.dart index d3c4e76..8a54c22 100644 --- a/sojorn_app/lib/models/post.dart +++ b/sojorn_app/lib/models/post.dart @@ -251,8 +251,11 @@ class Post { videoUrl: json['video_url'] as String?, thumbnailUrl: json['thumbnail_url'] as String?, durationMs: _parseInt(json['duration_ms']), - hasVideoContent: json['has_video_content'] as bool?, + hasVideoContent: json['has_video_content'] as bool? ?? + ((json['video_url'] as String?)?.isNotEmpty == true || + (json['image_url'] as String?)?.toLowerCase().endsWith('.mp4') == true), bodyFormat: json['body_format'] as String?, + backgroundId: json['background_id'] as String?, tags: _parseTags(json['tags']), reactions: _parseReactions( diff --git a/sojorn_app/lib/providers/quip_upload_provider.dart b/sojorn_app/lib/providers/quip_upload_provider.dart index 3b243e6..3374f8c 100644 --- a/sojorn_app/lib/providers/quip_upload_provider.dart +++ b/sojorn_app/lib/providers/quip_upload_provider.dart @@ -87,9 +87,11 @@ class QuipUploadNotifier extends Notifier { videoFile, onProgress: (p) => state = state.copyWith(progress: 0.1 + (p * 0.4)), ); + print('Video uploaded successfully: $videoUrl'); state = state.copyWith(progress: 0.5); + // Upload thumbnail to Go Backend / R2 String? thumbnailUrl; try { diff --git a/sojorn_app/lib/routes/app_routes.dart b/sojorn_app/lib/routes/app_routes.dart index 5fa32b5..7c2837d 100644 --- a/sojorn_app/lib/routes/app_routes.dart +++ b/sojorn_app/lib/routes/app_routes.dart @@ -100,8 +100,11 @@ class AppRoutes { routes: [ GoRoute( path: quips, - builder: (_, __) => const QuipsFeedScreen(), + builder: (_, state) => QuipsFeedScreen( + initialPostId: state.uri.queryParameters['postId'], + ), ), + ], ), StatefulShellBranch( @@ -195,7 +198,16 @@ class AppRoutes { return '$baseUrl/u/$username'; } - /// Get shareable URL for a post (future implementation) + /// Get shareable URL for a quip + /// Returns: https://sojorn.net/quips?postId=postid + static String getQuipUrl( + String postId, { + String baseUrl = 'https://sojorn.net', + }) { + return '$baseUrl/quips?postId=$postId'; + } + + /// Get shareable URL for a post /// Returns: https://sojorn.net/p/postid static String getPostUrl( String postId, { @@ -204,6 +216,7 @@ class AppRoutes { return '$baseUrl/p/$postId'; } + /// Get shareable URL for a beacon location /// Returns: https://sojorn.net/beacon?lat=...&long=... static String getBeaconUrl( diff --git a/sojorn_app/lib/screens/auth/sign_in_screen.dart b/sojorn_app/lib/screens/auth/sign_in_screen.dart index ed51186..8b9600b 100644 --- a/sojorn_app/lib/screens/auth/sign_in_screen.dart +++ b/sojorn_app/lib/screens/auth/sign_in_screen.dart @@ -31,7 +31,13 @@ class _SignInScreenState extends ConsumerState { bool _saveCredentials = true; String? _storedEmail; String? _storedPassword; - String? _captchaToken; + String? _turnstileToken; + + // Turnstile site key from environment or default production key + static const String _turnstileSiteKey = String.fromEnvironment( + 'TURNSTILE_SITE_KEY', + defaultValue: '0x4AAAAAACYFlz_g513d6xAf', // Cloudflare production key + ); static const _savedEmailKey = 'saved_login_email'; static const _savedPasswordKey = 'saved_login_password'; @@ -94,14 +100,13 @@ class _SignInScreenState extends ConsumerState { bool get _canUseBiometricLogin => _supportsBiometric && _hasStoredCredentials && - !_isBiometricAuthenticating; + !_isBiometricAuthenticating && + _turnstileToken != null; // Require Turnstile for biometric too Future _signIn() async { final email = _emailController.text.trim(); final password = _passwordController.text; - print('[SignIn] Attempting sign-in for $email'); - if (email.isEmpty || !email.contains('@')) { setState(() { _errorMessage = 'Please enter a valid email address'; @@ -116,6 +121,14 @@ class _SignInScreenState extends ConsumerState { return; } + // Validate Turnstile token + if (_turnstileToken == null || _turnstileToken!.isEmpty) { + setState(() { + _errorMessage = 'Please complete the security verification'; + }); + return; + } + setState(() { _isLoading = true; _errorMessage = null; @@ -123,18 +136,18 @@ class _SignInScreenState extends ConsumerState { try { final authService = ref.read(authServiceProvider); - print('[SignIn] Calling signInWithGoBackend...'); await authService.signInWithGoBackend( email: email, password: password, + turnstileToken: _turnstileToken!, ); - print('[SignIn] Sign-in successful!'); await _persistCredentials(email, password); } catch (e) { - print('[SignIn] Error: $e'); if (mounted) { setState(() { _errorMessage = e.toString().replaceAll('Exception: ', ''); + // Reset Turnstile token on error so user must re-verify + _turnstileToken = null; }); } } finally { @@ -382,7 +395,7 @@ class _SignInScreenState extends ConsumerState { obscureText: true, textInputAction: TextInputAction.done, prefixIcon: Icons.lock_outline, - onEditingComplete: _signIn, + onEditingComplete: _turnstileToken != null ? _signIn : null, autofillHints: const [AutofillHints.password], onChanged: (_) { if (_errorMessage != null) { @@ -393,6 +406,46 @@ class _SignInScreenState extends ConsumerState { }, ), const SizedBox(height: AppTheme.spacingLg), + + // Turnstile CAPTCHA + Container( + decoration: BoxDecoration( + border: Border.all( + color: _turnstileToken != null + ? AppTheme.success + : AppTheme.egyptianBlue.withOpacity(0.3), + width: 1, + ), + borderRadius: BorderRadius.circular(12), + ), + padding: const EdgeInsets.all(AppTheme.spacingMd), + child: Column( + children: [ + if (_turnstileToken == null) ...[ + TurnstileWidget( + siteKey: _turnstileSiteKey, + onToken: (token) { + setState(() { + _turnstileToken = token; + }); + }, + ), + ] else ...[ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.check_circle, color: AppTheme.success, size: 20), + const SizedBox(width: 8), + Text( + 'Security verified', + style: TextStyle(color: AppTheme.success, fontWeight: FontWeight.w600), + ), + ], + ), + ], + ], + ), + ), const SizedBox(height: AppTheme.spacingLg), if (_supportsBiometric) ...[ @@ -422,7 +475,7 @@ class _SignInScreenState extends ConsumerState { ], sojornButton( label: 'Sign In', - onPressed: isSubmitting ? null : _signIn, + onPressed: (isSubmitting || _turnstileToken == null) ? null : _signIn, isLoading: isSubmitting, isFullWidth: true, variant: sojornButtonVariant.primary, diff --git a/sojorn_app/lib/screens/auth/sign_up_screen.dart b/sojorn_app/lib/screens/auth/sign_up_screen.dart index 885476f..3e3c4fa 100644 --- a/sojorn_app/lib/screens/auth/sign_up_screen.dart +++ b/sojorn_app/lib/screens/auth/sign_up_screen.dart @@ -1,9 +1,11 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:url_launcher/url_launcher.dart'; import '../../providers/auth_provider.dart'; import '../../theme/app_theme.dart'; -import 'profile_setup_screen.dart'; -import 'category_select_screen.dart'; +import '../../widgets/auth/turnstile_widget.dart'; class SignUpScreen extends ConsumerStatefulWidget { const SignUpScreen({super.key}); @@ -21,6 +23,22 @@ class _SignUpScreenState extends ConsumerState { final _confirmPasswordController = TextEditingController(); bool _isLoading = false; String? _errorMessage; + + // Turnstile token + String? _turnstileToken; + + // Legal consent + bool _acceptTerms = false; + bool _acceptPrivacy = false; + + // Email preferences (single combined option) + bool _emailUpdates = false; + + // Turnstile site key from environment or default production key + static const String _turnstileSiteKey = String.fromEnvironment( + 'TURNSTILE_SITE_KEY', + defaultValue: '0x4AAAAAACYFlz_g513d6xAf', // Cloudflare production key + ); @override void dispose() { @@ -34,6 +52,29 @@ class _SignUpScreenState extends ConsumerState { Future _signUp() async { if (!_formKey.currentState!.validate()) return; + + // Validate Turnstile token + if (_turnstileToken == null || _turnstileToken!.isEmpty) { + setState(() { + _errorMessage = 'Please complete the security verification'; + }); + return; + } + + // Validate legal consent + if (!_acceptTerms) { + setState(() { + _errorMessage = 'You must accept the Terms of Service'; + }); + return; + } + + if (!_acceptPrivacy) { + setState(() { + _errorMessage = 'You must accept the Privacy Policy'; + }); + return; + } setState(() { _isLoading = true; @@ -47,33 +88,37 @@ class _SignUpScreenState extends ConsumerState { password: _passwordController.text, handle: _handleController.text.trim(), displayName: _displayNameController.text.trim(), + turnstileToken: _turnstileToken!, + acceptTerms: _acceptTerms, + acceptPrivacy: _acceptPrivacy, + emailNewsletter: _emailUpdates, + emailContact: _emailUpdates, ); - // Navigate to category selection (skip profile setup as it's done) if (mounted) { - // Show success message and navigate to sign in - showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('Verify your email'), - content: const Text( - 'A verification link has been sent to your email. Please check your inbox (and spam folder) to verify your account before logging in.'), - actions: [ - TextButton( - onPressed: () { - Navigator.of(context).pop(); // dialog - Navigator.of(context).pop(); // signup screen - }, - child: const Text('OK'), - ), - ], - ), - ); + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Verify your email'), + content: const Text( + 'A verification link has been sent to your email. Please check your inbox (and spam folder) to verify your account before logging in.'), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); // dialog + Navigator.of(context).pop(); // signup screen + }, + child: const Text('OK'), + ), + ], + ), + ); } } catch (e) { if (mounted) { setState(() { _errorMessage = e.toString().replaceAll('Exception: ', ''); + _turnstileToken = null; // Reset Turnstile on error }); } } finally { @@ -84,8 +129,13 @@ class _SignUpScreenState extends ConsumerState { } } } - + void _launchUrl(String url) async { + final uri = Uri.parse(url); + if (await canLaunchUrl(uri)) { + await launchUrl(uri, mode: LaunchMode.externalApplication); + } + } @override Widget build(BuildContext context) { @@ -113,31 +163,26 @@ class _SignUpScreenState extends ConsumerState { const SizedBox(height: AppTheme.spacingSm), Text( - 'Your vibrant journey begins now', // Updated tagline + 'Your vibrant journey begins now', style: AppTheme.bodyMedium.copyWith( - color: AppTheme.navyText.withOpacity( - 0.8), // Replaced AppTheme.textSecondary + color: AppTheme.navyText.withOpacity(0.8), ), textAlign: TextAlign.center, ), - const SizedBox( - height: AppTheme.spacingLg * - 1.5), // Replaced AppTheme.spacing2xl + const SizedBox(height: AppTheme.spacingLg * 1.5), // Error message if (_errorMessage != null) ...[ Container( padding: const EdgeInsets.all(AppTheme.spacingMd), decoration: BoxDecoration( - color: AppTheme.error - .withOpacity(0.1), // Replaced withValues + color: AppTheme.error.withOpacity(0.1), borderRadius: BorderRadius.circular(12), border: Border.all(color: AppTheme.error, width: 1), ), child: Text( _errorMessage!, style: AppTheme.textTheme.labelSmall?.copyWith( - // Replaced AppTheme.bodySmall color: AppTheme.error, ), ), @@ -151,10 +196,15 @@ class _SignUpScreenState extends ConsumerState { decoration: const InputDecoration( labelText: 'Handle (@username)', hintText: 'sojorn_user', + prefixIcon: Icon(Icons.alternate_email), ), validator: (value) { - if (value == null || value.isEmpty) return 'Handle is required'; - return null; + if (value == null || value.isEmpty) return 'Handle is required'; + if (value.length < 3) return 'Handle must be at least 3 characters'; + if (!RegExp(r'^[a-zA-Z0-9_]+$').hasMatch(value)) { + return 'Handle can only contain letters, numbers, and underscores'; + } + return null; }, ), const SizedBox(height: AppTheme.spacingMd), @@ -165,10 +215,11 @@ class _SignUpScreenState extends ConsumerState { decoration: const InputDecoration( labelText: 'Display Name', hintText: 'Jane Doe', + prefixIcon: Icon(Icons.person_outline), ), validator: (value) { - if (value == null || value.isEmpty) return 'Display Name is required'; - return null; + if (value == null || value.isEmpty) return 'Display Name is required'; + return null; }, ), const SizedBox(height: AppTheme.spacingMd), @@ -180,12 +231,13 @@ class _SignUpScreenState extends ConsumerState { decoration: const InputDecoration( labelText: 'Email', hintText: 'your@email.com', + prefixIcon: Icon(Icons.email_outlined), ), validator: (value) { if (value == null || value.isEmpty) { return 'Email is required'; } - if (!value.contains('@')) { + if (!value.contains('@') || !value.contains('.')) { return 'Enter a valid email'; } return null; @@ -200,6 +252,7 @@ class _SignUpScreenState extends ConsumerState { decoration: const InputDecoration( labelText: 'Password', hintText: 'At least 6 characters', + prefixIcon: Icon(Icons.lock_outline), ), validator: (value) { if (value == null || value.isEmpty) { @@ -219,6 +272,7 @@ class _SignUpScreenState extends ConsumerState { obscureText: true, decoration: const InputDecoration( labelText: 'Confirm Password', + prefixIcon: Icon(Icons.lock_outline), ), validator: (value) { if (value == null || value.isEmpty) { @@ -232,30 +286,131 @@ class _SignUpScreenState extends ConsumerState { ), const SizedBox(height: AppTheme.spacingLg), + // Turnstile CAPTCHA + Container( + decoration: BoxDecoration( + border: Border.all( + color: _turnstileToken != null + ? AppTheme.success + : AppTheme.egyptianBlue.withOpacity(0.3), + width: 1, + ), + borderRadius: BorderRadius.circular(12), + ), + padding: const EdgeInsets.all(AppTheme.spacingMd), + child: Column( + children: [ + if (_turnstileToken == null) ...[ + TurnstileWidget( + siteKey: _turnstileSiteKey, + onToken: (token) { + setState(() { + _turnstileToken = token; + }); + }, + ), + ] else ...[ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.check_circle, color: AppTheme.success, size: 20), + const SizedBox(width: 8), + Text( + 'Security verified', + style: TextStyle(color: AppTheme.success, fontWeight: FontWeight.w600), + ), + ], + ), + ], + ], + ), + ), + const SizedBox(height: AppTheme.spacingLg), + + // Terms of Service checkbox + CheckboxListTile( + value: _acceptTerms, + onChanged: (value) => setState(() => _acceptTerms = value ?? false), + controlAffinity: ListTileControlAffinity.leading, + contentPadding: EdgeInsets.zero, + dense: true, + title: RichText( + text: TextSpan( + style: AppTheme.textTheme.bodySmall?.copyWith(color: AppTheme.navyText), + children: [ + const TextSpan(text: 'I agree to the '), + TextSpan( + text: 'Terms of Service', + style: TextStyle(color: AppTheme.brightNavy, fontWeight: FontWeight.w600), + recognizer: TapGestureRecognizer() + ..onTap = () => _launchUrl('https://sojorn.net/terms'), + ), + ], + ), + ), + ), + + // Privacy Policy checkbox + CheckboxListTile( + value: _acceptPrivacy, + onChanged: (value) => setState(() => _acceptPrivacy = value ?? false), + controlAffinity: ListTileControlAffinity.leading, + contentPadding: EdgeInsets.zero, + dense: true, + title: RichText( + text: TextSpan( + style: AppTheme.textTheme.bodySmall?.copyWith(color: AppTheme.navyText), + children: [ + const TextSpan(text: 'I agree to the '), + TextSpan( + text: 'Privacy Policy', + style: TextStyle(color: AppTheme.brightNavy, fontWeight: FontWeight.w600), + recognizer: TapGestureRecognizer() + ..onTap = () => _launchUrl('https://sojorn.net/privacy'), + ), + ], + ), + ), + ), + + // Email updates preference (part of agreement section) + CheckboxListTile( + value: _emailUpdates, + onChanged: (value) => setState(() => _emailUpdates = value ?? false), + controlAffinity: ListTileControlAffinity.leading, + contentPadding: EdgeInsets.zero, + dense: true, + title: Text( + 'Please send me email updates about sojorn and MPLS LLC', + style: AppTheme.textTheme.bodySmall?.copyWith(color: AppTheme.navyText), + ), + ), + const SizedBox(height: AppTheme.spacingMd), + // Sign up button ElevatedButton( onPressed: _isLoading ? null : _signUp, + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + ), child: _isLoading ? const SizedBox( height: 20, width: 20, child: CircularProgressIndicator( strokeWidth: 2, - valueColor: - AlwaysStoppedAnimation(Colors.white), + valueColor: AlwaysStoppedAnimation(Colors.white), ), ) - : const Text('Continue'), + : const Text('Create Account'), ), const SizedBox(height: AppTheme.spacingMd), - // Terms and privacy note + // Footer Text( - 'By continuing, you agree to our vibrant community guidelines.\nA product of MPLS LLC.', // Updated text + 'A product of MPLS LLC.', style: AppTheme.textTheme.labelSmall?.copyWith( - // Replaced AppTheme.bodySmall - color: AppTheme - .egyptianBlue, // Replaced AppTheme.textTertiary + color: AppTheme.egyptianBlue, ), textAlign: TextAlign.center, ), diff --git a/sojorn_app/lib/screens/post/threaded_conversation_screen.dart b/sojorn_app/lib/screens/post/threaded_conversation_screen.dart index 0d4eb6b..60eb298 100644 --- a/sojorn_app/lib/screens/post/threaded_conversation_screen.dart +++ b/sojorn_app/lib/screens/post/threaded_conversation_screen.dart @@ -18,9 +18,11 @@ import '../secure_chat/secure_chat_full_screen.dart'; import '../../services/notification_service.dart'; import '../../widgets/post/post_body.dart'; import '../../widgets/post/post_view_mode.dart'; +import '../../widgets/post/post_media.dart'; import '../../providers/notification_provider.dart'; import 'package:share_plus/share_plus.dart'; + class ThreadedConversationScreen extends ConsumerStatefulWidget { final String rootPostId; final Post? rootPost; @@ -495,10 +497,14 @@ class _ThreadedConversationScreenState extends ConsumerState _sharePost(Post post) async { final handle = post.author?.handle ?? 'sojorn'; - final text = '${post.body}\n\n— @$handle on sojorn'; + final shareUrl = post.hasVideoContent == true + ? AppRoutes.getQuipUrl(post.id) + : AppRoutes.getPostUrl(post.id); + + + final text = '${post.body}\n\n$shareUrl\n\n— @$handle on Sojorn'; + try { await Share.share(text); diff --git a/sojorn_app/lib/screens/quips/feed/quips_feed_screen.dart b/sojorn_app/lib/screens/quips/feed/quips_feed_screen.dart index 92bb03b..0fcd652 100644 --- a/sojorn_app/lib/screens/quips/feed/quips_feed_screen.dart +++ b/sojorn_app/lib/screens/quips/feed/quips_feed_screen.dart @@ -67,7 +67,9 @@ class Quip { class QuipsFeedScreen extends ConsumerStatefulWidget { final bool? isActive; - const QuipsFeedScreen({super.key, this.isActive}); + final String? initialPostId; + const QuipsFeedScreen({super.key, this.isActive, this.initialPostId}); + @override ConsumerState createState() => _QuipsFeedScreenState(); @@ -99,9 +101,15 @@ class _QuipsFeedScreenState extends ConsumerState super.initState(); WidgetsBinding.instance.addObserver(this); _isScreenActive = widget.isActive ?? false; - _fetchQuips(); + if (widget.initialPostId != null) { + _isUserPaused = false; + } + _fetchQuips(refresh: widget.initialPostId != null); + } + + void _checkFeedRefresh() { final refreshToken = ref.read(feedRefreshProvider); if (refreshToken != _lastRefreshToken) { @@ -135,8 +143,14 @@ class _QuipsFeedScreenState extends ConsumerState if (widget.isActive != oldWidget.isActive) { _handleScreenActive(_resolveActiveState()); } + if (widget.initialPostId != oldWidget.initialPostId && widget.initialPostId != null) { + _isUserPaused = false; // Auto-play if user explicitly clicked a quip + _fetchQuips(refresh: true); + } + } + @override void didChangeDependencies() { super.didChangeDependencies(); @@ -227,11 +241,43 @@ class _QuipsFeedScreenState extends ConsumerState final posts = (data['posts'] as List? ?? []).whereType>(); - final items = posts + List items = posts .map(Quip.fromMap) .where((quip) => quip.videoUrl.isNotEmpty) .toList(); + // If we have an initialPostId, ensure it's at the top + // If we have an initialPostId, ensure it's at the top + if (refresh && widget.initialPostId != null) { + print('[Quips] Handling initialPostId: ${widget.initialPostId}'); + final existingIndex = items.indexWhere((q) => q.id == widget.initialPostId); + if (existingIndex != -1) { + print('[Quips] Found initialPostId in feed at index $existingIndex, moving to top'); + final initial = items.removeAt(existingIndex); + items.insert(0, initial); + } else { + print('[Quips] initialPostId NOT in feed, fetching specifically...'); + try { + final postData = await api.callGoApi('/posts/${widget.initialPostId}', method: 'GET'); + if (postData['post'] != null) { + final quip = Quip.fromMap(postData['post'] as Map); + if (quip.videoUrl.isNotEmpty) { + print('[Quips] Successfully fetched initial quip: ${quip.videoUrl}'); + items.insert(0, quip); + } else { + print('[Quips] Fetched post is not a video: ${quip.videoUrl}'); + } + } else { + print('[Quips] No post found for initialPostId: ${widget.initialPostId}'); + } + } catch (e) { + print('Initial quip fetch error: $e'); + } + } + } + + + if (!mounted) return; setState(() { if (refresh) { @@ -419,10 +465,13 @@ class _QuipsFeedScreenState extends ConsumerState } void _shareQuip(Quip quip) { - final url = AppRoutes.getPostUrl(quip.id); - Share.share(url); + final url = AppRoutes.getQuipUrl(quip.id); + final text = '${quip.caption}\n\n$url\n\n— @${quip.username} on Sojorn'; + Share.share(text); } + + @override Widget build(BuildContext context) { if (_error != null) { diff --git a/sojorn_app/lib/services/api_service.dart b/sojorn_app/lib/services/api_service.dart index f8b960a..9db39fe 100644 --- a/sojorn_app/lib/services/api_service.dart +++ b/sojorn_app/lib/services/api_service.dart @@ -578,8 +578,14 @@ class ApiService { if (long != null && (long < -180 || long > 180)) { throw ArgumentError('Invalid longitude range'); } - + + if (kDebugMode) { + print('[Post] Publishing: body=$body, video=$videoUrl, thumb=$thumbnailUrl'); + print('[Post] Sanitized: video=$sanitizedVideoUrl, thumb=$sanitizedThumbnailUrl'); + } + final data = await _callGoApi( + '/posts', method: 'POST', body: { @@ -588,9 +594,13 @@ class ApiService { 'body_format': bodyFormat, 'allow_chain': allowChain, if (chainParentId != null) 'chain_parent_id': chainParentId, - if (sanitizedImageUrl != null) 'image_url': sanitizedImageUrl, - if (sanitizedVideoUrl != null) 'video_url': sanitizedVideoUrl, - if (sanitizedThumbnailUrl != null) 'thumbnail_url': sanitizedThumbnailUrl, + if (sanitizedImageUrl != null || (imageUrl != null && imageUrl.isNotEmpty)) + 'image_url': sanitizedImageUrl ?? imageUrl, + if (sanitizedVideoUrl != null || (videoUrl != null && videoUrl.isNotEmpty)) + 'video_url': sanitizedVideoUrl ?? videoUrl, + if (sanitizedThumbnailUrl != null || (thumbnailUrl != null && thumbnailUrl.isNotEmpty)) + 'thumbnail_url': sanitizedThumbnailUrl ?? thumbnailUrl, + if (durationMs != null) 'duration_ms': durationMs, if (ttlHours != null) 'ttl_hours': ttlHours, if (isBeacon) 'is_beacon': true, @@ -762,11 +772,17 @@ class ApiService { } Future blockUser(String userId) async { - // Migrate to Go API + await _callGoApi( + '/users/$userId/block', + method: 'POST', + ); } Future unblockUser(String userId) async { - // Migrate to Go API + await _callGoApi( + '/users/$userId/block', + method: 'DELETE', + ); } Future appreciatePost(String postId) async { @@ -846,7 +862,7 @@ class ApiService { await _callGoApi('/conversations/$conversationId', method: 'DELETE'); return true; } catch (e) { - print('[API] Failed to delete conversation: $e'); + if (kDebugMode) print('[API] Failed to delete conversation: $e'); return false; } } @@ -856,7 +872,7 @@ class ApiService { await _callGoApi('/messages/$messageId', method: 'DELETE'); return true; } catch (e) { - print('[API] Failed to delete message: $e'); + if (kDebugMode) print('[API] Failed to delete message: $e'); return false; } } @@ -867,7 +883,7 @@ class ApiService { Future> getKeyBundle(String userId) async { final data = await callGoApi('/keys/$userId', method: 'GET'); - print('[API] Raw Key Bundle for $userId: ${jsonEncode(data)}'); + // Key bundle fetched - contents not logged for security // Go returns nested structure. We normalize to flat keys here. if (data.containsKey('identity_key') && data['identity_key'] is Map) { final identityKey = data['identity_key'] as Map; diff --git a/sojorn_app/lib/services/auth_service.dart b/sojorn_app/lib/services/auth_service.dart index e6b8cce..8f6887a 100644 --- a/sojorn_app/lib/services/auth_service.dart +++ b/sojorn_app/lib/services/auth_service.dart @@ -200,13 +200,18 @@ class AuthService { Future> signInWithGoBackend({ required String email, required String password, + required String turnstileToken, }) async { try { final uri = Uri.parse('${ApiConfig.baseUrl}/auth/login'); final response = await http.post( uri, headers: {'Content-Type': 'application/json'}, - body: jsonEncode({'email': email, 'password': password}), + body: jsonEncode({ + 'email': email, + 'password': password, + 'turnstile_token': turnstileToken, + }), ); final data = jsonDecode(response.body); @@ -253,6 +258,11 @@ class AuthService { required String password, required String handle, required String displayName, + required String turnstileToken, + required bool acceptTerms, + required bool acceptPrivacy, + bool emailNewsletter = false, + bool emailContact = false, }) async { try { final uri = Uri.parse('${ApiConfig.baseUrl}/auth/register'); @@ -264,6 +274,11 @@ class AuthService { 'password': password, 'handle': handle, 'display_name': displayName, + 'turnstile_token': turnstileToken, + 'accept_terms': acceptTerms, + 'accept_privacy': acceptPrivacy, + 'email_newsletter': emailNewsletter, + 'email_contact': emailContact, }), ); diff --git a/sojorn_app/lib/services/simple_e2ee_service.dart b/sojorn_app/lib/services/simple_e2ee_service.dart index fba3c22..9a9a871 100644 --- a/sojorn_app/lib/services/simple_e2ee_service.dart +++ b/sojorn_app/lib/services/simple_e2ee_service.dart @@ -73,8 +73,8 @@ class SimpleE2EEService { return _initFuture = _doInitialize(userId); } - // DEBUG: Set to true to force new key generation on startup (fixing bad keys) - static const bool _FORCE_KEY_ROTATION = false; + // Key rotation is now handled via initiateKeyRecovery() when needed + // DO NOT add debug flags here - use resetAllKeys() method for intentional resets Future resetAllKeys() async { print('[E2EE] RESETTING ALL KEYS - fixing MAC errors'); @@ -202,11 +202,7 @@ class SimpleE2EEService { Future _doInitialize(String userId) async { _initializedForUserId = userId; - if (_FORCE_KEY_ROTATION) { - print('[E2EE] FORCE_KEY_ROTATION is true. Skipping load/restore and generating NEW identity.'); - await generateNewIdentity(); - return; - } + // 1. Try Local Storage try { @@ -640,7 +636,7 @@ class SimpleE2EEService { final secretBox = SecretBox(ciphertextBytes, nonce: nonce, mac: Mac(macBytes)); final plaintextBytes = await _cipher.decrypt(secretBox, secretKey: SecretKey(rootSecret)); final plaintext = utf8.decode(plaintextBytes); - print('[DECRYPT] SUCCESS: Decrypted message: "$plaintext"'); + // Decryption successful - plaintext not logged for security return plaintext; } catch (e) { print('[DECRYPT] Failed: $e'); diff --git a/sojorn_app/lib/utils/security_utils.dart b/sojorn_app/lib/utils/security_utils.dart index 4ad7dec..63d4df1 100644 --- a/sojorn_app/lib/utils/security_utils.dart +++ b/sojorn_app/lib/utils/security_utils.dart @@ -62,8 +62,9 @@ class SecurityUtils { final dangerousPatterns = [ 'script', 'javascript', 'vbscript', 'onload', 'onerror', 'onclick', 'eval', 'expression', 'alert', 'confirm', 'prompt', - '<', '>', '"', "'", '\\', '/', '\n', '\r', '\t' + '<', '>', '"', "'", '\\', '\n', '\r', '\t' ]; + final lowerValue = value.toLowerCase(); return dangerousPatterns.any((pattern) => lowerValue.contains(pattern)); diff --git a/sojorn_app/lib/widgets/post/post_media.dart b/sojorn_app/lib/widgets/post/post_media.dart index 03833d4..19322dd 100644 --- a/sojorn_app/lib/widgets/post/post_media.dart +++ b/sojorn_app/lib/widgets/post/post_media.dart @@ -1,5 +1,8 @@ import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; import '../../models/post.dart'; +import '../../routes/app_routes.dart'; + import '../../theme/app_theme.dart'; import '../media/signed_media_image.dart'; import 'post_view_mode.dart'; @@ -14,62 +17,74 @@ class PostMedia extends StatelessWidget { final Post? post; final Widget? child; final PostViewMode mode; + final VoidCallback? onTap; const PostMedia({ super.key, this.post, this.child, this.mode = PostViewMode.feed, + this.onTap, }); + /// Get image height based on view mode double get _imageHeight { switch (mode) { case PostViewMode.feed: - return 300.0; + return 450.0; // Taller for better resolution/ratio case PostViewMode.detail: - return 500.0; // Full height for detail view + return 600.0; case PostViewMode.compact: - return 200.0; // Smaller for profile lists + return 200.0; } } + @override Widget build(BuildContext context) { - if (post != null && post!.imageUrl != null && post!.imageUrl!.isNotEmpty) { + // Determine which URL to display as the cover + final String? displayUrl = (post?.imageUrl?.isNotEmpty == true) + ? post!.imageUrl + : (post?.thumbnailUrl?.isNotEmpty == true) + ? post!.thumbnailUrl + : null; + + if (displayUrl != null) { + final bool isVideo = post?.hasVideoContent == true; + return Padding( padding: const EdgeInsets.only(top: AppTheme.spacingSm), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - ConstrainedBox( - constraints: BoxConstraints(maxHeight: _imageHeight), - child: SizedBox( + ClipRRect( + borderRadius: BorderRadius.circular(AppTheme.radiusMd), + child: Container( width: double.infinity, - child: SignedMediaImage( - url: post!.imageUrl!, - fit: BoxFit.cover, - loadingBuilder: (context) => Container( - color: AppTheme.queenPink.withValues(alpha: 0.3), - child: const Center(child: CircularProgressIndicator()), - ), - errorBuilder: (context, error, stackTrace) => Container( - color: Colors.red.withValues(alpha: 0.3), - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.broken_image, - size: 48, color: Colors.white), - const SizedBox(height: 8), - Text('Error: $error', - style: const TextStyle( - color: Colors.white, fontSize: 10)), - ], - ), - ), - ), + // For videos in feed mode, use a more vertical 4:5 aspect ratio + // For other modes or non-videos, use the constrained height logic + child: InkWell( + onTap: isVideo + ? () { + final url = '${AppRoutes.quips}?postId=${post!.id}'; + print('[PostMedia] Navigating to quips: $url'); + context.go(url); + } + : onTap, + + + child: (isVideo && mode == PostViewMode.feed) + ? AspectRatio( + aspectRatio: 4 / 5, + child: _buildMediaContent(displayUrl, true), + ) + : ConstrainedBox( + constraints: BoxConstraints(maxHeight: _imageHeight), + child: _buildMediaContent(displayUrl, isVideo), + ), ), + ), ), ], @@ -89,4 +104,52 @@ class PostMedia extends StatelessWidget { ), ); } + + Widget _buildMediaContent(String displayUrl, bool isVideo) { + return Stack( + fit: StackFit.expand, + children: [ + SignedMediaImage( + url: displayUrl, + fit: (isVideo && mode == PostViewMode.feed) ? BoxFit.cover : BoxFit.cover, + loadingBuilder: (context) => Container( + color: AppTheme.queenPink.withValues(alpha: 0.3), + child: const Center(child: CircularProgressIndicator()), + ), + errorBuilder: (context, error, stackTrace) => Container( + color: Colors.red.withValues(alpha: 0.3), + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.broken_image, size: 48, color: Colors.white), + const SizedBox(height: 8), + Text('Error: $error', + style: const TextStyle(color: Colors.white, fontSize: 10)), + ], + ), + ), + ), + ), + // Play Button Overlay for Video + if (isVideo) + Center( + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.5), + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 2), + ), + child: const Icon( + Icons.play_arrow, + color: Colors.white, + size: 40, + ), + ), + ), + ], + ); + } } + diff --git a/sojorn_app/lib/widgets/sojorn_post_card.dart b/sojorn_app/lib/widgets/sojorn_post_card.dart index ad962dd..f376353 100644 --- a/sojorn_app/lib/widgets/sojorn_post_card.dart +++ b/sojorn_app/lib/widgets/sojorn_post_card.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import '../models/post.dart'; + import '../theme/app_theme.dart'; import 'post/post_actions.dart'; import 'post/post_body.dart'; @@ -195,18 +196,21 @@ class sojornPostCard extends StatelessWidget { ), ), - // Media (if available) - clickable for post detail - if (post.imageUrl != null && post.imageUrl!.isNotEmpty) ...[ + // Media (if available) - clickable for post detail (or quip player if video) + if ((post.imageUrl != null && post.imageUrl!.isNotEmpty) || + (post.thumbnailUrl != null && post.thumbnailUrl!.isNotEmpty) || + (post.videoUrl != null && post.videoUrl!.isNotEmpty)) ...[ const SizedBox(height: 12), - InkWell( + PostMedia( + post: post, + mode: mode, onTap: onTap, - child: PostMedia( - post: post, - mode: mode, - ), ), ], + + + // Actions section - with padding const SizedBox(height: 16), Padding( diff --git a/sojorn_app/pubspec.yaml b/sojorn_app/pubspec.yaml index 22f3204..629e8e6 100644 --- a/sojorn_app/pubspec.yaml +++ b/sojorn_app/pubspec.yaml @@ -27,7 +27,7 @@ dependencies: google_fonts: ^6.2.1 share_plus: ^10.0.2 timeago: ^3.7.0 - url_launcher: ^6.3.1 + url_launcher: ^6.3.2 image_picker: ^1.1.2 image: ^4.3.0 flutter_image_compress: ^2.4.0 diff --git a/sojorn_app/run_dev.ps1 b/sojorn_app/run_dev.ps1 index 758db56..fdb839c 100644 --- a/sojorn_app/run_dev.ps1 +++ b/sojorn_app/run_dev.ps1 @@ -40,12 +40,17 @@ $defineArgs = @( "--dart-define=API_BASE_URL=$($values['API_BASE_URL'])" ) +if ($values.ContainsKey('TURNSTILE_SITE_KEY') -and -not [string]::IsNullOrWhiteSpace($values['TURNSTILE_SITE_KEY'])) { + $defineArgs += "--dart-define=TURNSTILE_SITE_KEY=$($values['TURNSTILE_SITE_KEY'])" +} + Write-Host "Starting Sojorn in development mode..." -ForegroundColor Green Write-Host "" Push-Location $PSScriptRoot try { flutter run @defineArgs @Args -} finally { +} +finally { Pop-Location } diff --git a/verified_fixed.html b/verified_fixed.html new file mode 100644 index 0000000..214f315 --- /dev/null +++ b/verified_fixed.html @@ -0,0 +1,197 @@ + + + + + + + Email Verification - Sojorn + + + + + +
+
+ + + + +
+
+ + + +
+

Verification Required

+

We couldn't confirm your verification status. Please use the link sent to your email address.

+ Return to App +
+
+ + + + +