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
+
+
+
+
+
+
+

+
%s
+
+
+
+
+
%s
+
+ %s
+
+
+
+ %s
+
+
+ %s
+
+
+
+
+
Ā© 2026 Sojorn by MPLS LLC. All rights reserved.
+
+
+
+
+
+
+ `, 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