feat: Implement comprehensive reaction display widget, add numerous new screens, services, models, documentation, and configuration files.

This commit is contained in:
Patrick Britton 2026-02-04 10:57:00 -06:00
parent 72ae644758
commit f77bd72c57
60 changed files with 626 additions and 436 deletions

1
.gitignore vendored
View file

@ -153,3 +153,4 @@ temp_server.env
.zshrc .zshrc
.profile .profile
sojorn_docs/SOJORN_ARCHITECTURE.md

View file

@ -1,3 +1,3 @@
api.gosojorn.com { api.sojorn.net {
reverse_proxy localhost:8080 reverse_proxy localhost:8080
} }

View file

@ -1,7 +1,7 @@
import { AwsClient } from 'https://esm.sh/aws4fetch@1.0.17' import { AwsClient } from 'https://esm.sh/aws4fetch@1.0.17'
const CUSTOM_MEDIA_DOMAIN = (Deno.env.get("CUSTOM_MEDIA_DOMAIN") ?? "https://img.gosojorn.com").trim(); const CUSTOM_MEDIA_DOMAIN = (Deno.env.get("CUSTOM_MEDIA_DOMAIN") ?? "https://img.sojorn.net").trim();
const CUSTOM_VIDEO_DOMAIN = (Deno.env.get("CUSTOM_VIDEO_DOMAIN") ?? "https://quips.gosojorn.com").trim(); const CUSTOM_VIDEO_DOMAIN = (Deno.env.get("CUSTOM_VIDEO_DOMAIN") ?? "https://quips.sojorn.net").trim();
const DEFAULT_BUCKET_NAME = "sojorn-media"; const DEFAULT_BUCKET_NAME = "sojorn-media";
const RESOLVED_BUCKET = (Deno.env.get("R2_BUCKET_NAME") ?? DEFAULT_BUCKET_NAME).trim(); const RESOLVED_BUCKET = (Deno.env.get("R2_BUCKET_NAME") ?? DEFAULT_BUCKET_NAME).trim();
@ -36,8 +36,8 @@ export function transformLegacyMediaUrl(input: string): string | null {
try { try {
const url = new URL(trimmed); const url = new URL(trimmed);
// Handle legacy media.gosojorn.com URLs // Handle legacy media.sojorn.net URLs
if (url.hostname === 'media.gosojorn.com') { if (url.hostname === 'media.sojorn.net') {
const key = decodeURIComponent(url.pathname); const key = decodeURIComponent(url.pathname);
return key; return key;
} }

View file

@ -2,7 +2,7 @@ import { serve } from 'https://deno.land/std@0.177.0/http/server.ts';
import { S3Client, DeleteObjectCommand } from 'https://esm.sh/@aws-sdk/client-s3@3.470.0'; import { S3Client, DeleteObjectCommand } from 'https://esm.sh/@aws-sdk/client-s3@3.470.0';
import { createServiceClient } from '../_shared/supabase-client.ts'; import { createServiceClient } from '../_shared/supabase-client.ts';
const ALLOWED_ORIGIN = Deno.env.get('ALLOWED_ORIGIN') || 'https://gosojorn.com'; const ALLOWED_ORIGIN = Deno.env.get('ALLOWED_ORIGIN') || 'https://sojorn.net';
const corsHeaders = { const corsHeaders = {
'Access-Control-Allow-Origin': ALLOWED_ORIGIN, 'Access-Control-Allow-Origin': ALLOWED_ORIGIN,

View file

@ -11,7 +11,7 @@
import { serve } from 'https://deno.land/std@0.177.0/http/server.ts'; import { serve } from 'https://deno.land/std@0.177.0/http/server.ts';
import { createSupabaseClient } from '../_shared/supabase-client.ts'; import { createSupabaseClient } from '../_shared/supabase-client.ts';
const ALLOWED_ORIGIN = Deno.env.get('ALLOWED_ORIGIN') || 'https://gosojorn.com'; const ALLOWED_ORIGIN = Deno.env.get('ALLOWED_ORIGIN') || 'https://sojorn.net';
serve(async (req) => { serve(async (req) => {
if (req.method === 'OPTIONS') { if (req.method === 'OPTIONS') {

View file

@ -12,7 +12,7 @@
import { serve } from 'https://deno.land/std@0.177.0/http/server.ts'; import { serve } from 'https://deno.land/std@0.177.0/http/server.ts';
import { createSupabaseClient } from '../_shared/supabase-client.ts'; import { createSupabaseClient } from '../_shared/supabase-client.ts';
const ALLOWED_ORIGIN = Deno.env.get('ALLOWED_ORIGIN') || 'https://gosojorn.com'; const ALLOWED_ORIGIN = Deno.env.get('ALLOWED_ORIGIN') || 'https://sojorn.net';
serve(async (req) => { serve(async (req) => {
if (req.method === 'OPTIONS') { if (req.method === 'OPTIONS') {

View file

@ -2,7 +2,7 @@
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'; import { serve } from 'https://deno.land/std@0.168.0/http/server.ts';
import { createSupabaseClient, createServiceClient } from '../_shared/supabase-client.ts'; import { createSupabaseClient, createServiceClient } from '../_shared/supabase-client.ts';
const ALLOWED_ORIGIN = Deno.env.get('ALLOWED_ORIGIN') || 'https://gosojorn.com'; const ALLOWED_ORIGIN = Deno.env.get('ALLOWED_ORIGIN') || 'https://sojorn.net';
const corsHeaders = { const corsHeaders = {
'Access-Control-Allow-Origin': ALLOWED_ORIGIN, 'Access-Control-Allow-Origin': ALLOWED_ORIGIN,

View file

@ -55,7 +55,7 @@ serve(async (req) => {
}); });
} }
// Transform legacy media.gosojorn.com URLs to their object key // Transform legacy media.sojorn.net URLs to their object key
const transformedTarget = transformLegacyMediaUrl(target) ?? target; const transformedTarget = transformLegacyMediaUrl(target) ?? target;
const signedUrl = await trySignR2Url(transformedTarget, expiresIn); const signedUrl = await trySignR2Url(transformedTarget, expiresIn);

View file

@ -26,7 +26,7 @@ serve(async (req) => {
if (req.method === 'OPTIONS') { if (req.method === 'OPTIONS') {
return new Response(null, { return new Response(null, {
headers: { headers: {
'Access-Control-Allow-Origin': Deno.env.get('ALLOWED_ORIGIN') || 'https://gosojorn.com', 'Access-Control-Allow-Origin': Deno.env.get('ALLOWED_ORIGIN') || 'https://sojorn.net',
'Access-Control-Allow-Methods': 'POST', 'Access-Control-Allow-Methods': 'POST',
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
}, },

View file

@ -3,7 +3,7 @@ import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
const OPENAI_MODERATION_URL = 'https://api.openai.com/v1/moderations' const OPENAI_MODERATION_URL = 'https://api.openai.com/v1/moderations'
const ALLOWED_ORIGIN = Deno.env.get('ALLOWED_ORIGIN') || 'https://gosojorn.com'; const ALLOWED_ORIGIN = Deno.env.get('ALLOWED_ORIGIN') || 'https://sojorn.net';
const corsHeaders = { const corsHeaders = {
'Access-Control-Allow-Origin': ALLOWED_ORIGIN, 'Access-Control-Allow-Origin': ALLOWED_ORIGIN,

View file

@ -49,7 +49,7 @@
</div> </div>
<div class="flex items-center gap-6"> <div class="flex items-center gap-6">
<a href="/email" class="text-sm font-semibold hover:text-egyptianBlue transition">Newsletter</a> <a href="/email" class="text-sm font-semibold hover:text-egyptianBlue transition">Newsletter</a>
<a href="mailto:contact@gosojorn.com" <a href="mailto:contact@sojorn.net"
class="text-sm font-semibold hover:text-egyptianBlue transition">Contact</a> class="text-sm font-semibold hover:text-egyptianBlue transition">Contact</a>
</div> </div>
</nav> </nav>
@ -119,8 +119,8 @@
</div> </div>
<p class="mt-6 text-sm text-gray-500"> <p class="mt-6 text-sm text-gray-500">
Join the waitlist by emailing <a href="mailto:waitlist@gosojorn.com" Join the waitlist by emailing <a href="mailto:waitlist@sojorn.net"
class="text-egyptianBlue underline">waitlist@gosojorn.com</a> class="text-egyptianBlue underline">waitlist@sojorn.net</a>
</p> </p>
</div> </div>
</main> </main>

View file

@ -73,8 +73,8 @@
you leave, you leave. We do not retain hidden profiles.</p> you leave, you leave. We do not retain hidden profiles.</p>
<h2 class="text-xl font-bold mt-6 mb-4">5. Contact</h2> <h2 class="text-xl font-bold mt-6 mb-4">5. Contact</h2>
<p class="mb-4">For privacy concerns: <a href="mailto:privacy@gosojorn.com" <p class="mb-4">For privacy concerns: <a href="mailto:privacy@sojorn.net"
class="text-egyptianBlue underline">privacy@gosojorn.com</a>.</p> class="text-egyptianBlue underline">privacy@sojorn.net</a>.</p>
</div> </div>
</main> </main>

View file

@ -1,28 +1,28 @@
-- Fix sojorn-media URLs (for images) to img.gosojorn.com -- Fix sojorn-media URLs (for images) to img.sojorn.net
UPDATE profiles UPDATE profiles
SET avatar_url = REGEXP_REPLACE(avatar_url, 'https://[a-zA-Z0-9]+\.r2\.cloudflarestorage\.com/sojorn-media/', 'https://img.gosojorn.com/', 'g') SET avatar_url = REGEXP_REPLACE(avatar_url, 'https://[a-zA-Z0-9]+\.r2\.cloudflarestorage\.com/sojorn-media/', 'https://img.sojorn.net/', 'g')
WHERE avatar_url LIKE '%r2.cloudflarestorage.com/sojorn-media%'; WHERE avatar_url LIKE '%r2.cloudflarestorage.com/sojorn-media%';
UPDATE profiles UPDATE profiles
SET cover_url = REGEXP_REPLACE(cover_url, 'https://[a-zA-Z0-9]+\.r2\.cloudflarestorage\.com/sojorn-media/', 'https://img.gosojorn.com/', 'g') SET cover_url = REGEXP_REPLACE(cover_url, 'https://[a-zA-Z0-9]+\.r2\.cloudflarestorage\.com/sojorn-media/', 'https://img.sojorn.net/', 'g')
WHERE cover_url LIKE '%r2.cloudflarestorage.com/sojorn-media%'; WHERE cover_url LIKE '%r2.cloudflarestorage.com/sojorn-media%';
UPDATE posts UPDATE posts
SET image_url = REGEXP_REPLACE(image_url, 'https://[a-zA-Z0-9]+\.r2\.cloudflarestorage\.com/sojorn-media/', 'https://img.gosojorn.com/', 'g') SET image_url = REGEXP_REPLACE(image_url, 'https://[a-zA-Z0-9]+\.r2\.cloudflarestorage\.com/sojorn-media/', 'https://img.sojorn.net/', 'g')
WHERE image_url LIKE '%r2.cloudflarestorage.com/sojorn-media%'; WHERE image_url LIKE '%r2.cloudflarestorage.com/sojorn-media%';
UPDATE posts UPDATE posts
SET thumbnail_url = REGEXP_REPLACE(thumbnail_url, 'https://[a-zA-Z0-9]+\.r2\.cloudflarestorage\.com/sojorn-media/', 'https://img.gosojorn.com/', 'g') SET thumbnail_url = REGEXP_REPLACE(thumbnail_url, 'https://[a-zA-Z0-9]+\.r2\.cloudflarestorage\.com/sojorn-media/', 'https://img.sojorn.net/', 'g')
WHERE thumbnail_url LIKE '%r2.cloudflarestorage.com/sojorn-media%'; WHERE thumbnail_url LIKE '%r2.cloudflarestorage.com/sojorn-media%';
-- Fix sojorn-videos URLs (for quips) to quips.gosojorn.com -- Fix sojorn-videos URLs (for quips) to quips.sojorn.net
UPDATE posts UPDATE posts
SET video_url = REGEXP_REPLACE(video_url, 'https://[a-zA-Z0-9]+\.r2\.cloudflarestorage\.com/sojorn-videos/', 'https://quips.gosojorn.com/', 'g') SET video_url = REGEXP_REPLACE(video_url, 'https://[a-zA-Z0-9]+\.r2\.cloudflarestorage\.com/sojorn-videos/', 'https://quips.sojorn.net/', 'g')
WHERE video_url LIKE '%r2.cloudflarestorage.com/sojorn-videos%'; WHERE video_url LIKE '%r2.cloudflarestorage.com/sojorn-videos%';
-- Fix the one edge case where image_url contains a video URL -- Fix the one edge case where image_url contains a video URL
UPDATE posts UPDATE posts
SET image_url = REGEXP_REPLACE(image_url, 'https://[a-zA-Z0-9]+\.r2\.cloudflarestorage\.com/sojorn-videos/', 'https://quips.gosojorn.com/', 'g') SET image_url = REGEXP_REPLACE(image_url, 'https://[a-zA-Z0-9]+\.r2\.cloudflarestorage\.com/sojorn-videos/', 'https://quips.sojorn.net/', 'g')
WHERE image_url LIKE '%r2.cloudflarestorage.com/sojorn-videos%'; WHERE image_url LIKE '%r2.cloudflarestorage.com/sojorn-videos%';
-- Verify after -- Verify after

View file

@ -3,7 +3,7 @@
-- 1. Fix image URLs that are just filenames (no domain) -- 1. Fix image URLs that are just filenames (no domain)
UPDATE posts UPDATE posts
SET image_url = 'https://img.gosojorn.com/' || image_url SET image_url = 'https://img.sojorn.net/' || image_url
WHERE image_url IS NOT NULL WHERE image_url IS NOT NULL
AND image_url != '' AND image_url != ''
AND image_url NOT LIKE 'http%' AND image_url NOT LIKE 'http%'
@ -11,7 +11,7 @@ WHERE image_url IS NOT NULL
-- 2. Fix video URLs that are just filenames (no domain) -- 2. Fix video URLs that are just filenames (no domain)
UPDATE posts UPDATE posts
SET video_url = 'https://quips.gosojorn.com/' || video_url SET video_url = 'https://quips.sojorn.net/' || video_url
WHERE video_url IS NOT NULL WHERE video_url IS NOT NULL
AND video_url != '' AND video_url != ''
AND video_url NOT LIKE 'http%' AND video_url NOT LIKE 'http%'
@ -19,7 +19,7 @@ WHERE video_url IS NOT NULL
-- 3. Fix thumbnail URLs that are just filenames -- 3. Fix thumbnail URLs that are just filenames
UPDATE posts UPDATE posts
SET thumbnail_url = 'https://img.gosojorn.com/' || thumbnail_url SET thumbnail_url = 'https://img.sojorn.net/' || thumbnail_url
WHERE thumbnail_url IS NOT NULL WHERE thumbnail_url IS NOT NULL
AND thumbnail_url != '' AND thumbnail_url != ''
AND thumbnail_url NOT LIKE 'http%' AND thumbnail_url NOT LIKE 'http%'
@ -27,7 +27,7 @@ WHERE thumbnail_url IS NOT NULL
-- 4. Fix profile avatars that are just filenames -- 4. Fix profile avatars that are just filenames
UPDATE profiles UPDATE profiles
SET avatar_url = 'https://img.gosojorn.com/' || avatar_url SET avatar_url = 'https://img.sojorn.net/' || avatar_url
WHERE avatar_url IS NOT NULL WHERE avatar_url IS NOT NULL
AND avatar_url != '' AND avatar_url != ''
AND avatar_url NOT LIKE 'http%' AND avatar_url NOT LIKE 'http%'
@ -35,7 +35,7 @@ WHERE avatar_url IS NOT NULL
-- 5. Fix profile covers that are just filenames -- 5. Fix profile covers that are just filenames
UPDATE profiles UPDATE profiles
SET cover_url = 'https://img.gosojorn.com/' || cover_url SET cover_url = 'https://img.sojorn.net/' || cover_url
WHERE cover_url IS NOT NULL WHERE cover_url IS NOT NULL
AND cover_url != '' AND cover_url != ''
AND cover_url NOT LIKE 'http%' AND cover_url NOT LIKE 'http%'
@ -51,12 +51,12 @@ SELECT 'Profiles with covers' as check_type, count(*) as count FROM profiles WHE
-- Show any remaining non-standard URLs -- Show any remaining non-standard URLs
SELECT 'Non-standard image URLs' as type, image_url FROM posts SELECT 'Non-standard image URLs' as type, image_url FROM posts
WHERE image_url IS NOT NULL WHERE image_url IS NOT NULL
AND image_url NOT LIKE 'https://img.gosojorn.com/%' AND image_url NOT LIKE 'https://img.sojorn.net/%'
AND image_url NOT LIKE 'https://quips.gosojorn.com/%' AND image_url NOT LIKE 'https://quips.sojorn.net/%'
LIMIT 5; LIMIT 5;
SELECT 'Non-standard video URLs' as type, video_url FROM posts SELECT 'Non-standard video URLs' as type, video_url FROM posts
WHERE video_url IS NOT NULL WHERE video_url IS NOT NULL
AND video_url NOT LIKE 'https://img.gosojorn.com/%' AND video_url NOT LIKE 'https://img.sojorn.net/%'
AND video_url NOT LIKE 'https://quips.gosojorn.com/%' AND video_url NOT LIKE 'https://quips.sojorn.net/%'
LIMIT 5; LIMIT 5;

View file

@ -0,0 +1,34 @@
# Redirect Legacy HTTP -> New HTTPS (gosojorn.com & gojorn.com)
server {
server_name gosojorn.com api.gosojorn.com gojorn.com www.gojorn.com;
listen 80;
return 301 https://sojorn.net$request_uri;
}
# Redirect Legacy HTTPS -> New HTTPS (gosojorn.com ONLY)
# We can only serve SSL for domains we have certs for (gosojorn)
server {
server_name gosojorn.com;
listen 443 ssl;
# Use EXISTING legacy certificates
ssl_certificate /etc/letsencrypt/live/gosojorn.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/gosojorn.com/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
return 301 https://sojorn.net$request_uri;
}
server {
server_name api.gosojorn.com;
listen 443 ssl;
# Use EXISTING legacy certificates
ssl_certificate /etc/letsencrypt/live/api.gosojorn.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/api.gosojorn.com/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
return 301 https://api.sojorn.net$request_uri;
}

View file

@ -1,6 +1,6 @@
server { server {
listen 80; listen 80;
server_name api.gosojorn.com; server_name api.sojorn.net;
location / { location / {
proxy_pass http://localhost:8080; proxy_pass http://localhost:8080;
@ -12,7 +12,7 @@ server {
server { server {
listen 80; listen 80;
server_name gosojorn.com; server_name sojorn.net;
root /var/www/sojorn; root /var/www/sojorn;
index index.html; index index.html;

View file

@ -1,5 +1,5 @@
server { server {
server_name api.gosojorn.com; server_name api.sojorn.net;
location / { location / {
proxy_pass http://localhost:8080; proxy_pass http://localhost:8080;
@ -14,14 +14,14 @@ server {
} }
listen 443 ssl; # managed by Certbot listen 443 ssl; # managed by Certbot
ssl_certificate /etc/letsencrypt/live/api.gosojorn.com/fullchain.pem; # managed by Certbot ssl_certificate /etc/letsencrypt/live/api.sojorn.net/fullchain.pem; # managed by Certbot
ssl_certificate_key /etc/letsencrypt/live/api.gosojorn.com/privkey.pem; # managed by Certbot ssl_certificate_key /etc/letsencrypt/live/api.sojorn.net/privkey.pem; # managed by Certbot
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
} }
server { server {
server_name gosojorn.com; server_name sojorn.net;
root /var/www/sojorn; root /var/www/sojorn;
index index.html; index index.html;
@ -30,21 +30,21 @@ server {
} }
listen 443 ssl; # managed by Certbot listen 443 ssl; # managed by Certbot
ssl_certificate /etc/letsencrypt/live/gosojorn.com/fullchain.pem; # managed by Certbot ssl_certificate /etc/letsencrypt/live/sojorn.net/fullchain.pem; # managed by Certbot
ssl_certificate_key /etc/letsencrypt/live/gosojorn.com/privkey.pem; # managed by Certbot ssl_certificate_key /etc/letsencrypt/live/sojorn.net/privkey.pem; # managed by Certbot
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
} }
server { server {
if ($host = api.gosojorn.com) { if ($host = api.sojorn.net) {
return 301 https://$host$request_uri; return 301 https://$host$request_uri;
} }
if ($host = gosojorn.com) { if ($host = sojorn.net) {
return 301 https://$host$request_uri; return 301 https://$host$request_uri;
} }
listen 80; listen 80;
server_name api.gosojorn.com gosojorn.com; server_name api.sojorn.net sojorn.net;
return 404; return 404;
} }

34
nginx/sojorn_net.conf Normal file
View file

@ -0,0 +1,34 @@
# sojorn.net - Frontend
server {
server_name sojorn.net www.sojorn.net;
root /var/www/sojorn;
index index.html;
location / {
try_files $uri $uri/ =404;
}
# Certbot will add SSL configuration here
listen 80;
}
# api.sojorn.net - Backend
server {
server_name api.sojorn.net;
location / {
proxy_pass http://localhost:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# WebSocket support
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
# Certbot will add SSL configuration here
listen 80;
}

View file

@ -1,5 +1,5 @@
server { server {
server_name gosojorn.com www.gosojorn.com; server_name sojorn.net www.sojorn.net;
root /var/www/sojorn; root /var/www/sojorn;
index index.html; index index.html;
@ -28,18 +28,18 @@ server {
} }
listen 443 ssl; # managed by Certbot listen 443 ssl; # managed by Certbot
ssl_certificate /etc/letsencrypt/live/api.gosojorn.com/fullchain.pem; ssl_certificate /etc/letsencrypt/live/api.sojorn.net/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/api.gosojorn.com/privkey.pem; ssl_certificate_key /etc/letsencrypt/live/api.sojorn.net/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
} }
server { server {
if ($host = gosojorn.com) { if ($host = sojorn.net) {
return 301 https://$host$request_uri; return 301 https://$host$request_uri;
} # managed by Certbot } # managed by Certbot
listen 80; listen 80;
server_name gosojorn.com www.gosojorn.com; server_name sojorn.net www.sojorn.net;
return 404; # managed by Certbot return 404; # managed by Certbot
} }

View file

@ -10,7 +10,7 @@ function Parse-Env($path) {
if (-not (Test-Path $path)) { if (-not (Test-Path $path)) {
Write-Host "No .env file found at ${path}. Using defaults." -ForegroundColor Yellow Write-Host "No .env file found at ${path}. Using defaults." -ForegroundColor Yellow
# Set default API_BASE_URL since no .env exists # Set default API_BASE_URL since no .env exists
$vals['API_BASE_URL'] = 'https://api.gosojorn.com/api/v1' $vals['API_BASE_URL'] = 'https://api.sojorn.net/api/v1'
return $vals return $vals
} }
Get-Content $path | ForEach-Object { Get-Content $path | ForEach-Object {
@ -41,16 +41,18 @@ foreach ($k in $keysOfInterest) {
# Ensure API_BASE_URL is set # Ensure API_BASE_URL is set
if (-not $values.ContainsKey('API_BASE_URL') -or [string]::IsNullOrWhiteSpace($values['API_BASE_URL'])) { if (-not $values.ContainsKey('API_BASE_URL') -or [string]::IsNullOrWhiteSpace($values['API_BASE_URL'])) {
$currentApi = 'https://api.gosojorn.com/api/v1' $currentApi = 'https://api.sojorn.net/api/v1'
$defineArgs += "--dart-define=API_BASE_URL=$currentApi" $defineArgs += "--dart-define=API_BASE_URL=$currentApi"
} else { }
else {
$currentApi = $values['API_BASE_URL'] $currentApi = $values['API_BASE_URL']
# Always ensure we're using the HTTPS endpoint # Always ensure we're using the HTTPS endpoint
if ($currentApi.StartsWith('http://api.gosojorn.com:8080')) { if ($currentApi.StartsWith('http://api.sojorn.net:8080')) {
$currentApi = $currentApi.Replace('http://api.gosojorn.com:8080', 'https://api.gosojorn.com') $currentApi = $currentApi.Replace('http://api.sojorn.net:8080', 'https://api.sojorn.net')
$defineArgs = $defineArgs | Where-Object { -not ($_ -like '--dart-define=API_BASE_URL=*') } $defineArgs = $defineArgs | Where-Object { -not ($_ -like '--dart-define=API_BASE_URL=*') }
$defineArgs += "--dart-define=API_BASE_URL=$currentApi" $defineArgs += "--dart-define=API_BASE_URL=$currentApi"
} elseif ($currentApi.StartsWith('http://localhost:')) { }
elseif ($currentApi.StartsWith('http://localhost:')) {
# For local development, keep localhost but warn # For local development, keep localhost but warn
Write-Host "Using local API: $currentApi" -ForegroundColor Yellow Write-Host "Using local API: $currentApi" -ForegroundColor Yellow
} }

View file

@ -10,7 +10,7 @@ function Parse-Env($path) {
if (-not (Test-Path $path)) { if (-not (Test-Path $path)) {
Write-Host "No .env file found at ${path}. Using defaults." -ForegroundColor Yellow Write-Host "No .env file found at ${path}. Using defaults." -ForegroundColor Yellow
# Set default API_BASE_URL since no .env exists # Set default API_BASE_URL since no .env exists
$vals['API_BASE_URL'] = 'https://api.gosojorn.com/api/v1' $vals['API_BASE_URL'] = 'https://api.sojorn.net/api/v1'
return $vals return $vals
} }
Get-Content $path | ForEach-Object { Get-Content $path | ForEach-Object {
@ -41,16 +41,18 @@ foreach ($k in $keysOfInterest) {
# Ensure API_BASE_URL is set # Ensure API_BASE_URL is set
if (-not $values.ContainsKey('API_BASE_URL') -or [string]::IsNullOrWhiteSpace($values['API_BASE_URL'])) { if (-not $values.ContainsKey('API_BASE_URL') -or [string]::IsNullOrWhiteSpace($values['API_BASE_URL'])) {
$currentApi = 'https://api.gosojorn.com/api/v1' $currentApi = 'https://api.sojorn.net/api/v1'
$defineArgs += "--dart-define=API_BASE_URL=$currentApi" $defineArgs += "--dart-define=API_BASE_URL=$currentApi"
} else { }
else {
$currentApi = $values['API_BASE_URL'] $currentApi = $values['API_BASE_URL']
# Always ensure we're using the HTTPS endpoint # Always ensure we're using the HTTPS endpoint
if ($currentApi.StartsWith('http://api.gosojorn.com:8080')) { if ($currentApi.StartsWith('http://api.sojorn.net:8080')) {
$currentApi = $currentApi.Replace('http://api.gosojorn.com:8080', 'https://api.gosojorn.com') $currentApi = $currentApi.Replace('http://api.sojorn.net:8080', 'https://api.sojorn.net')
$defineArgs = $defineArgs | Where-Object { -not ($_ -like '--dart-define=API_BASE_URL=*') } $defineArgs = $defineArgs | Where-Object { -not ($_ -like '--dart-define=API_BASE_URL=*') }
$defineArgs += "--dart-define=API_BASE_URL=$currentApi" $defineArgs += "--dart-define=API_BASE_URL=$currentApi"
} elseif ($currentApi.StartsWith('http://localhost:')) { }
elseif ($currentApi.StartsWith('http://localhost:')) {
# For local development, keep localhost but warn # For local development, keep localhost but warn
Write-Host "Using local API: $currentApi" -ForegroundColor Yellow Write-Host "Using local API: $currentApi" -ForegroundColor Yellow
} }

View file

@ -9,7 +9,7 @@ function Parse-Env($path) {
if (-not (Test-Path $path)) { if (-not (Test-Path $path)) {
Write-Host "No .env file found at ${path}. Using defaults." -ForegroundColor Yellow Write-Host "No .env file found at ${path}. Using defaults." -ForegroundColor Yellow
# Set default API_BASE_URL since no .env exists # Set default API_BASE_URL since no .env exists
$vals['API_BASE_URL'] = 'https://api.gosojorn.com/api/v1' $vals['API_BASE_URL'] = 'https://api.sojorn.net/api/v1'
return $vals return $vals
} }
Get-Content $path | ForEach-Object { Get-Content $path | ForEach-Object {
@ -40,9 +40,10 @@ foreach ($k in $keysOfInterest) {
# Ensure API_BASE_URL is set # Ensure API_BASE_URL is set
if (-not $values.ContainsKey('API_BASE_URL') -or [string]::IsNullOrWhiteSpace($values['API_BASE_URL'])) { if (-not $values.ContainsKey('API_BASE_URL') -or [string]::IsNullOrWhiteSpace($values['API_BASE_URL'])) {
$currentApi = 'https://api.gosojorn.com/api/v1' $currentApi = 'https://api.sojorn.net/api/v1'
$defineArgs += "--dart-define=API_BASE_URL=$currentApi" $defineArgs += "--dart-define=API_BASE_URL=$currentApi"
} else { }
else {
$currentApi = $values['API_BASE_URL'] $currentApi = $values['API_BASE_URL']
if ($currentApi.StartsWith('http://localhost:')) { if ($currentApi.StartsWith('http://localhost:')) {
# For local development, keep localhost but warn # For local development, keep localhost but warn
@ -71,7 +72,8 @@ try {
if ($Release) { if ($Release) {
$cmdArgs += '--release' $cmdArgs += '--release'
} else { }
else {
$cmdArgs += '--debug' $cmdArgs += '--debug'
} }
@ -99,10 +101,12 @@ try {
Write-Host "Starting Sojorn Windows app..." -ForegroundColor Yellow Write-Host "Starting Sojorn Windows app..." -ForegroundColor Yellow
Start-Process -FilePath $exePath Start-Process -FilePath $exePath
} }
} else { }
else {
Write-Host "Build completed but executable not found at: $exePath" -ForegroundColor Red Write-Host "Build completed but executable not found at: $exePath" -ForegroundColor Red
} }
} else { }
else {
Write-Host "Build failed!" -ForegroundColor Red Write-Host "Build failed!" -ForegroundColor Red
} }
} }

View file

@ -1,6 +1,6 @@
server { server {
listen 80; listen 80;
server_name api.gosojorn.com; server_name api.sojorn.net;
# Allow Certbot to validate (it uses .well-known/acme-challenge) # Allow Certbot to validate (it uses .well-known/acme-challenge)
location /.well-known/acme-challenge/ { location /.well-known/acme-challenge/ {

View file

@ -1,15 +1,15 @@
server { server {
listen 80; listen 80;
server_name api.gosojorn.com; server_name api.sojorn.net;
return 301 https://api.gosojorn.com$request_uri; return 301 https://api.sojorn.net$request_uri;
} }
server { server {
listen 443 ssl http2; listen 443 ssl http2;
server_name api.gosojorn.com; server_name api.sojorn.net;
ssl_certificate /etc/letsencrypt/live/api.gosojorn.com/fullchain.pem; ssl_certificate /etc/letsencrypt/live/api.sojorn.net/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/api.gosojorn.com/privkey.pem; ssl_certificate_key /etc/letsencrypt/live/api.sojorn.net/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf; include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;

View file

@ -1,15 +1,15 @@
server { server {
listen 80; listen 80;
server_name api.gosojorn.com; server_name api.sojorn.net;
return 301 https://api.gosojorn.com$request_uri; return 301 https://api.sojorn.net$request_uri;
} }
server { server {
listen 443 ssl http2; listen 443 ssl http2;
server_name api.gosojorn.com; server_name api.sojorn.net;
ssl_certificate /etc/letsencrypt/live/gosojorn.com/fullchain.pem; ssl_certificate /etc/letsencrypt/live/sojorn.net/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/gosojorn.com/privkey.pem; ssl_certificate_key /etc/letsencrypt/live/sojorn.net/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf; include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;

View file

@ -43,7 +43,7 @@
<action android:name="android.intent.action.VIEW" /> <action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" /> <category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="sojorn" android:host="beacon" /> <data android:scheme="https" android:host="sojorn.net" />
</intent-filter> </intent-filter>
<!-- Deep link for verification: sojorn://verified --> <!-- Deep link for verification: sojorn://verified -->
<intent-filter> <intent-filter>

View file

@ -1,4 +1,4 @@
<resources> <resources>
<string name="default_notification_channel_id">chat_messages</string> <string name="default_notification_channel_id">sojorn_notifications</string>
<string name="default_notification_channel_name">Chat messages</string> <string name="default_notification_channel_name">Sojorn Notifications</string>
</resources> </resources>

View file

@ -5,19 +5,24 @@ class ApiConfig {
static String _computeBaseUrl() { static String _computeBaseUrl() {
final raw = const String.fromEnvironment( final raw = const String.fromEnvironment(
'API_BASE_URL', 'API_BASE_URL',
defaultValue: 'https://api.gosojorn.com/api/v1', defaultValue: 'https://api.sojorn.net/api/v1',
); );
// Auto-upgrade any lingering http://api.gosojorn.com:8080 (or plain http) // Auto-upgrade any lingering http://api.sojorn.net:8080 (or plain http)
// to the public https endpoint behind nginx. This protects old .env files // to the public https endpoint behind nginx. This protects old .env files
// or cached web builds that still point at the closed port 8080. // or cached web builds that still point at the closed port 8080.
if (raw.startsWith('http://api.gosojorn.com:8080')) { if (raw.startsWith('http://api.sojorn.net:8080')) {
return raw.replaceFirst( return raw.replaceFirst(
'http://api.gosojorn.com:8080', 'http://api.sojorn.net:8080',
'https://api.gosojorn.com', 'https://api.sojorn.net',
); );
} }
// Belt-and-suspenders: Force migration even if args/env are stale
if (raw.contains('gosojorn.com')) {
return raw.replaceAll('gosojorn.com', 'sojorn.net');
}
if (raw.startsWith('http://')) { if (raw.startsWith('http://')) {
return 'https://${raw.substring('http://'.length)}'; return 'https://${raw.substring('http://'.length)}';
} }

View file

@ -2,14 +2,19 @@ import 'profile.dart';
/// Types of notifications /// Types of notifications
enum NotificationType { enum NotificationType {
appreciate, // Someone appreciated your post like, // Someone liked your post
chain, // Someone chained your post comment, // Someone commented on your post
follow, // Someone followed you reply, // Someone replied to your post (chained)
follow_request, // Someone requested to follow you mention, // Someone mentioned you
new_follower, // Someone followed you (public or approved) follow, // Someone followed you
request_accepted, // Someone accepted your follow request follow_request, // Someone requested to follow you
comment, // Someone commented on your post follow_accepted, // Someone accepted your follow request
mention, // Someone mentioned you message, // New chat message (if shown in notifications)
save, // Someone saved your post
beacon_vouch, // Someone vouched for your beacon
beacon_report, // Someone reported your beacon
share, // Someone shared your post
quip_reaction, // Someone reacted to your quip
} }
/// Notification model /// Notification model
@ -115,22 +120,32 @@ class AppNotification {
String get message { String get message {
final actorName = actor?.displayName ?? 'Someone'; final actorName = actor?.displayName ?? 'Someone';
switch (type) { switch (type) {
case NotificationType.appreciate: case NotificationType.like:
return '$actorName appreciated your post'; return '$actorName liked your post';
case NotificationType.chain: case NotificationType.reply:
return '$actorName chained your post'; return '$actorName replied to your post';
case NotificationType.follow: case NotificationType.follow:
return '$actorName started following you'; return '$actorName started following you';
case NotificationType.follow_request: case NotificationType.follow_request:
return '$actorName requested to follow you'; return '$actorName requested to follow you';
case NotificationType.new_follower: case NotificationType.follow_accepted:
return '$actorName followed you';
case NotificationType.request_accepted:
return '$actorName accepted your follow request'; return '$actorName accepted your follow request';
case NotificationType.comment: case NotificationType.comment:
return '$actorName commented on your post'; return '$actorName commented on your post';
case NotificationType.mention: case NotificationType.mention:
return '$actorName mentioned you'; return '$actorName mentioned you';
case NotificationType.message:
return '$actorName sent you a message';
case NotificationType.save:
return '$actorName saved your post';
case NotificationType.beacon_vouch:
return '$actorName vouched for your beacon';
case NotificationType.beacon_report:
return '$actorName reported your beacon';
case NotificationType.share:
return '$actorName shared your post';
case NotificationType.quip_reaction:
return '$actorName reacted to your quip';
} }
} }
} }

View file

@ -16,9 +16,9 @@ class SearchUser {
factory SearchUser.fromJson(Map<String, dynamic> json) { factory SearchUser.fromJson(Map<String, dynamic> json) {
return SearchUser( return SearchUser(
id: json['id'] as String, id: json['id'] as String? ?? '',
username: json['username'] as String, username: (json['username'] as String?) ?? (json['handle'] as String?) ?? 'unknown',
displayName: json['display_name'] as String? ?? json['displayName'] as String? ?? json['username'] as String, displayName: json['display_name'] as String? ?? json['displayName'] as String? ?? json['handle'] as String? ?? json['username'] as String? ?? 'Unknown',
avatarUrl: json['avatar_url'] as String?, avatarUrl: json['avatar_url'] as String?,
harmonyTier: json['harmony_tier'] as String? ?? json['harmonyTier'] as String? ?? 'new', harmonyTier: json['harmony_tier'] as String? ?? json['harmonyTier'] as String? ?? 'new',
); );
@ -81,12 +81,15 @@ class SearchPost {
}); });
factory SearchPost.fromJson(Map<String, dynamic> json) { factory SearchPost.fromJson(Map<String, dynamic> json) {
// Handle both flat structure and nested author object structure
final authorJson = json['author'] as Map<String, dynamic>?;
return SearchPost( return SearchPost(
id: json['id'] as String, id: json['id'] as String,
body: json['body'] as String, body: json['body'] as String,
authorId: json['author_id'] as String, authorId: json['author_id'] as String? ?? authorJson?['id'] as String? ?? '',
authorHandle: json['author_handle'] as String, authorHandle: json['author_handle'] as String? ?? authorJson?['handle'] as String? ?? 'unknown',
authorDisplayName: json['author_display_name'] as String, authorDisplayName: json['author_display_name'] as String? ?? authorJson?['display_name'] as String? ?? 'Unknown',
createdAt: DateTime.parse(json['created_at'] as String), createdAt: DateTime.parse(json['created_at'] as String),
); );
} }

View file

@ -19,6 +19,7 @@ import '../screens/profile/blocked_users_screen.dart';
import '../screens/auth/auth_gate.dart'; import '../screens/auth/auth_gate.dart';
import '../screens/discover/discover_screen.dart'; import '../screens/discover/discover_screen.dart';
import '../screens/secure_chat/secure_chat_full_screen.dart'; import '../screens/secure_chat/secure_chat_full_screen.dart';
import '../screens/post/threaded_conversation_screen.dart';
/// App routing config (GoRouter). /// App routing config (GoRouter).
class AppRoutes { class AppRoutes {
@ -65,6 +66,13 @@ class AppRoutes {
parentNavigatorKey: rootNavigatorKey, parentNavigatorKey: rootNavigatorKey,
builder: (_, __) => const SecureChatFullScreen(), builder: (_, __) => const SecureChatFullScreen(),
), ),
GoRoute(
path: '$postPrefix/:id',
parentNavigatorKey: rootNavigatorKey,
builder: (_, state) => ThreadedConversationScreen(
rootPostId: state.pathParameters['id'] ?? '',
),
),
StatefulShellRoute.indexedStack( StatefulShellRoute.indexedStack(
builder: (context, state, navigationShell) => AuthGate( builder: (context, state, navigationShell) => AuthGate(
authenticatedChild: HomeShell(navigationShell: navigationShell), authenticatedChild: HomeShell(navigationShell: navigationShell),
@ -81,16 +89,16 @@ class AppRoutes {
StatefulShellBranch( StatefulShellBranch(
routes: [ routes: [
GoRoute( GoRoute(
path: '/discover', path: quips,
builder: (_, __) => const DiscoverScreen(), builder: (_, __) => const QuipsFeedScreen(),
), ),
], ],
), ),
StatefulShellBranch( StatefulShellBranch(
routes: [ routes: [
GoRoute( GoRoute(
path: quips, path: '/beacon',
builder: (_, __) => const QuipsFeedScreen(), builder: (_, __) => const BeaconScreen(),
), ),
], ],
), ),
@ -169,29 +177,29 @@ class AppRoutes {
} }
/// Get shareable URL for a user profile /// Get shareable URL for a user profile
/// Returns: https://gosojorn.com/u/username /// Returns: https://sojorn.net/u/username
static String getProfileUrl( static String getProfileUrl(
String username, { String username, {
String baseUrl = 'https://gosojorn.com', String baseUrl = 'https://sojorn.net',
}) { }) {
return '$baseUrl/u/$username'; return '$baseUrl/u/$username';
} }
/// Get shareable URL for a post (future implementation) /// Get shareable URL for a post (future implementation)
/// Returns: https://gosojorn.com/p/postid /// Returns: https://sojorn.net/p/postid
static String getPostUrl( static String getPostUrl(
String postId, { String postId, {
String baseUrl = 'https://gosojorn.com', String baseUrl = 'https://sojorn.net',
}) { }) {
return '$baseUrl/p/$postId'; return '$baseUrl/p/$postId';
} }
/// Get shareable URL for a beacon location /// Get shareable URL for a beacon location
/// Returns: https://gosojorn.com/beacon?lat=...&long=... /// Returns: https://sojorn.net/beacon?lat=...&long=...
static String getBeaconUrl( static String getBeaconUrl(
double lat, double lat,
double long, { double long, {
String baseUrl = 'https://gosojorn.com', String baseUrl = 'https://sojorn.net',
}) { }) {
return '$baseUrl/beacon?lat=${lat.toStringAsFixed(6)}&long=${long.toStringAsFixed(6)}'; return '$baseUrl/beacon?lat=${lat.toStringAsFixed(6)}&long=${long.toStringAsFixed(6)}';
} }

View file

@ -1,4 +1,5 @@
import 'dart:async'; import 'dart:async';
import '../../models/profile.dart';
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
@ -81,9 +82,8 @@ class _DiscoverScreenState extends ConsumerState<DiscoverScreen> {
DiscoverData? discoverData; DiscoverData? discoverData;
List<RecentSearch> recentSearches = []; List<RecentSearch> recentSearches = [];
int _searchEpoch = 0; int _searchEpoch = 0;
final Map<String, Future<Post>> _postFutures = {};
static const Duration debounceDuration = Duration(milliseconds: 250); static const Duration debounceDuration = Duration(milliseconds: 300);
@override @override
void initState() { void initState() {
@ -186,7 +186,9 @@ class _DiscoverScreenState extends ConsumerState<DiscoverScreen> {
} }
debounceTimer = Timer(debounceDuration, () { debounceTimer = Timer(debounceDuration, () {
performSearch(query); if (query.length >= 2) {
performSearch(query);
}
}); });
} }
@ -259,18 +261,6 @@ class _DiscoverScreenState extends ConsumerState<DiscoverScreen> {
); );
} }
Future<Post> _getPostFuture(String postId) {
return _postFutures.putIfAbsent(postId, () {
final apiService = ref.read(apiServiceProvider);
return apiService.getPostById(postId);
});
}
void _retryPostLoad(String postId) {
_postFutures.remove(postId);
if (mounted) setState(() {});
}
void _openPostDetail(Post post) { void _openPostDetail(Post post) {
Navigator.of(context, rootNavigator: true).push( Navigator.of(context, rootNavigator: true).push(
MaterialPageRoute( MaterialPageRoute(
@ -668,76 +658,35 @@ class _DiscoverScreenState extends ConsumerState<DiscoverScreen> {
} }
Widget _buildPostResultItem(SearchPost post) { Widget _buildPostResultItem(SearchPost post) {
return FutureBuilder<Post>( // Convert SearchPost to minimal Post immediately
future: _getPostFuture(post.id), final minimalPost = Post(
builder: (context, snapshot) { id: post.id,
if (snapshot.connectionState == ConnectionState.waiting) { body: post.body,
return Container( authorId: post.authorId,
margin: const EdgeInsets.only(bottom: 12), createdAt: post.createdAt,
padding: const EdgeInsets.all(16), status: PostStatus.active,
decoration: BoxDecoration( detectedTone: ToneLabel.neutral,
color: AppTheme.cardSurface, contentIntegrityScore: 0.0,
borderRadius: BorderRadius.circular(12), author: Profile(
border: Border.all(color: AppTheme.egyptianBlue.withOpacity(0.2)), id: post.authorId,
), handle: post.authorHandle,
child: Row( displayName: post.authorDisplayName,
children: [ createdAt: DateTime.now(),
SizedBox( avatarUrl: null,
width: 20, ),
height: 20, isLiked: false,
child: CircularProgressIndicator( likeCount: 0,
strokeWidth: 2, commentCount: 0,
color: AppTheme.royalPurple, tags: [],
), );
),
const SizedBox(width: 12),
Text('Loading post...', style: AppTheme.bodyMedium),
],
),
);
}
if (snapshot.hasError || !snapshot.hasData) { return Padding(
return Container( padding: const EdgeInsets.only(bottom: 12),
margin: const EdgeInsets.only(bottom: 12), child: sojornPostCard(
padding: const EdgeInsets.all(16), post: minimalPost,
decoration: BoxDecoration( onTap: () => _openPostDetail(minimalPost),
color: AppTheme.cardSurface, onChain: () => _openChainComposer(minimalPost),
borderRadius: BorderRadius.circular(12), ),
border: Border.all(color: AppTheme.egyptianBlue.withOpacity(0.2)),
),
child: Row(
children: [
Icon(Icons.error_outline,
color: AppTheme.egyptianBlue.withOpacity(0.6)),
const SizedBox(width: 12),
Expanded(
child: Text(
'Unable to load post',
style: AppTheme.bodyMedium,
),
),
TextButton(
onPressed: () => _retryPostLoad(post.id),
child: Text('Retry',
style: AppTheme.labelMedium
.copyWith(color: AppTheme.royalPurple)),
),
],
),
);
}
final fullPost = snapshot.data!;
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: sojornPostCard(
post: fullPost,
onTap: () => _openPostDetail(fullPost),
onChain: () => _openChainComposer(fullPost),
),
);
},
); );
} }
} }

View file

@ -1,6 +1,9 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'dart:async';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:google_fonts/google_fonts.dart'; import 'package:google_fonts/google_fonts.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../../services/notification_service.dart';
import '../../services/secure_chat_service.dart'; import '../../services/secure_chat_service.dart';
import '../../theme/app_theme.dart'; import '../../theme/app_theme.dart';
import '../notifications/notifications_screen.dart'; import '../notifications/notifications_screen.dart';
@ -28,18 +31,29 @@ class HomeShell extends StatefulWidget {
class _HomeShellState extends State<HomeShell> with WidgetsBindingObserver { class _HomeShellState extends State<HomeShell> with WidgetsBindingObserver {
bool _isRadialMenuVisible = false; bool _isRadialMenuVisible = false;
final SecureChatService _chatService = SecureChatService(); final SecureChatService _chatService = SecureChatService();
StreamSubscription<RemoteMessage>? _notifSub;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
WidgetsBinding.instance.addObserver(this); WidgetsBinding.instance.addObserver(this);
_chatService.startBackgroundSync(); _chatService.startBackgroundSync();
_initNotificationListener();
}
void _initNotificationListener() {
_notifSub = NotificationService.instance.foregroundMessages.listen((message) {
if (mounted) {
NotificationService.instance.showNotificationBanner(context, message);
}
});
} }
@override @override
void dispose() { void dispose() {
WidgetsBinding.instance.removeObserver(this); WidgetsBinding.instance.removeObserver(this);
_chatService.stopBackgroundSync(); _chatService.stopBackgroundSync();
_notifSub?.cancel();
super.dispose(); super.dispose();
} }
@ -171,17 +185,17 @@ class _HomeShellState extends State<HomeShell> with WidgetsBindingObserver {
label: 'Home', label: 'Home',
), ),
_buildNavBarItem( _buildNavBarItem(
icon: Icons.explore_outlined, icon: Icons.play_circle_outline,
activeIcon: Icons.explore, activeIcon: Icons.play_circle,
index: 1, index: 1,
label: 'Discover', label: 'Quips',
), ),
const SizedBox(width: 48), const SizedBox(width: 48),
_buildNavBarItem( _buildNavBarItem(
icon: Icons.play_circle_outline, icon: Icons.sensors_outlined,
activeIcon: Icons.play_circle, activeIcon: Icons.sensors,
index: 2, index: 2,
label: 'Quips', label: 'Beacon',
), ),
_buildNavBarItem( _buildNavBarItem(
icon: Icons.person_outline, icon: Icons.person_outline,
@ -222,7 +236,11 @@ class _HomeShellState extends State<HomeShell> with WidgetsBindingObserver {
icon: Icon(Icons.search, color: AppTheme.navyBlue), icon: Icon(Icons.search, color: AppTheme.navyBlue),
tooltip: 'Discover', tooltip: 'Discover',
onPressed: () { onPressed: () {
widget.navigationShell.goBranch(1); Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => const DiscoverScreen(),
),
);
}, },
), ),
IconButton( IconButton(

View file

@ -243,8 +243,7 @@ class _NotificationsScreenState extends ConsumerState<NotificationsScreen> {
// Navigate based on notification type // Navigate based on notification type
switch (notification.type) { switch (notification.type) {
case NotificationType.follow: case NotificationType.follow:
case NotificationType.new_follower: case NotificationType.follow_accepted:
case NotificationType.request_accepted:
// Navigate to the follower's profile // Navigate to the follower's profile
if (notification.actor != null) { if (notification.actor != null) {
Navigator.of(context).push( Navigator.of(context).push(
@ -255,9 +254,9 @@ class _NotificationsScreenState extends ConsumerState<NotificationsScreen> {
} }
break; break;
case NotificationType.appreciate: case NotificationType.like:
case NotificationType.comment: case NotificationType.comment:
case NotificationType.chain: case NotificationType.reply:
case NotificationType.mention: case NotificationType.mention:
// Fetch the post and navigate to post detail // Fetch the post and navigate to post detail
if (notification.postId != null) { if (notification.postId != null) {
@ -284,8 +283,18 @@ class _NotificationsScreenState extends ConsumerState<NotificationsScreen> {
} }
} }
break; break;
case NotificationType.message:
// For messages, navigate to chat screen
if (notification.metadata?['conversation_id'] != null) {
context.push('/secure-chat/${notification.metadata!['conversation_id']}');
} else {
context.push('/secure-chat');
}
break;
case NotificationType.follow_request: case NotificationType.follow_request:
break; break;
default:
break;
} }
} }
@ -492,20 +501,19 @@ class _NotificationItem extends StatelessWidget {
Color iconColor; Color iconColor;
switch (notification.type) { switch (notification.type) {
case NotificationType.appreciate: case NotificationType.like:
iconData = Icons.favorite; iconData = Icons.favorite;
iconColor = AppTheme.brightNavy; iconColor = AppTheme.brightNavy;
break; break;
case NotificationType.chain: case NotificationType.reply:
iconData = Icons.subdirectory_arrow_right; iconData = Icons.subdirectory_arrow_right;
iconColor = AppTheme.royalPurple; iconColor = AppTheme.royalPurple;
break; break;
case NotificationType.follow: case NotificationType.follow:
case NotificationType.new_follower:
iconData = Icons.person_add; iconData = Icons.person_add;
iconColor = AppTheme.ksuPurple; iconColor = AppTheme.ksuPurple;
break; break;
case NotificationType.request_accepted: case NotificationType.follow_accepted:
iconData = Icons.check_circle; iconData = Icons.check_circle;
iconColor = AppTheme.brightNavy; iconColor = AppTheme.brightNavy;
break; break;
@ -521,6 +529,18 @@ class _NotificationItem extends StatelessWidget {
iconData = Icons.person_add; iconData = Icons.person_add;
iconColor = AppTheme.ksuPurple; iconColor = AppTheme.ksuPurple;
break; break;
case NotificationType.message:
iconData = Icons.message;
iconColor = AppTheme.egyptianBlue;
break;
case NotificationType.save:
iconData = Icons.bookmark;
iconColor = AppTheme.ksuPurple;
break;
default:
iconData = Icons.notifications;
iconColor = AppTheme.egyptianBlue;
break;
} }
return Container( return Container(

View file

@ -13,6 +13,9 @@ import '../../theme/app_theme.dart';
import '../../widgets/post/interactive_reply_block.dart'; import '../../widgets/post/interactive_reply_block.dart';
import '../../widgets/media/signed_media_image.dart'; import '../../widgets/media/signed_media_image.dart';
import '../compose/compose_screen.dart'; import '../compose/compose_screen.dart';
import '../discover/discover_screen.dart';
import '../secure_chat/secure_chat_full_screen.dart';
import 'package:share_plus/share_plus.dart';
class ThreadedConversationScreen extends ConsumerStatefulWidget { class ThreadedConversationScreen extends ConsumerStatefulWidget {
final String rootPostId; final String rootPostId;
@ -176,7 +179,31 @@ class _ThreadedConversationScreenState extends ConsumerState<ThreadedConversatio
), ),
), ),
), ),
actions: const [], actions: [
IconButton(
onPressed: () => context.go(AppRoutes.homeAlias),
icon: Icon(Icons.home_outlined, color: AppTheme.navyBlue),
),
IconButton(
onPressed: () => Navigator.of(context, rootNavigator: true).push(
MaterialPageRoute(
builder: (_) => const DiscoverScreen(),
fullscreenDialog: true,
),
),
icon: Icon(Icons.search, color: AppTheme.navyBlue),
),
IconButton(
onPressed: () => Navigator.of(context, rootNavigator: true).push(
MaterialPageRoute(
builder: (_) => SecureChatFullScreen(),
fullscreenDialog: true,
),
),
icon: Icon(Icons.chat_bubble_outline, color: AppTheme.navyBlue),
),
const SizedBox(width: 8),
],
); );
} }
@ -583,9 +610,12 @@ class _ThreadedConversationScreenState extends ConsumerState<ThreadedConversatio
icon: const Icon(Icons.reply, size: 18), icon: const Icon(Icons.reply, size: 18),
label: const Text('Reply'), label: const Text('Reply'),
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.brightNavy, backgroundColor: AppTheme.navyBlue.withValues(alpha: 0.05),
foregroundColor: Colors.white, foregroundColor: AppTheme.navyBlue,
padding: const EdgeInsets.symmetric(vertical: 14), elevation: 0,
shadowColor: Colors.transparent,
minimumSize: const Size(0, 44),
padding: const EdgeInsets.symmetric(horizontal: 16),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
@ -594,13 +624,14 @@ class _ThreadedConversationScreenState extends ConsumerState<ThreadedConversatio
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
IconButton( IconButton(
onPressed: () => _toggleLike(post), onPressed: () => _sharePost(post),
icon: Icon( icon: Icon(
isLiked ? Icons.favorite : Icons.favorite_border, Icons.share_outlined,
color: isLiked ? Colors.red : AppTheme.textSecondary, color: AppTheme.textSecondary,
), ),
style: IconButton.styleFrom( style: IconButton.styleFrom(
backgroundColor: AppTheme.navyBlue.withValues(alpha: 0.08), backgroundColor: AppTheme.navyBlue.withValues(alpha: 0.05),
minimumSize: const Size(44, 44),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
@ -614,7 +645,8 @@ class _ThreadedConversationScreenState extends ConsumerState<ThreadedConversatio
color: isSaved ? AppTheme.brightNavy : AppTheme.textSecondary, color: isSaved ? AppTheme.brightNavy : AppTheme.textSecondary,
), ),
style: IconButton.styleFrom( style: IconButton.styleFrom(
backgroundColor: AppTheme.navyBlue.withValues(alpha: 0.08), backgroundColor: AppTheme.navyBlue.withValues(alpha: 0.05),
minimumSize: const Size(44, 44),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
@ -996,20 +1028,14 @@ class _ThreadedConversationScreenState extends ConsumerState<ThreadedConversatio
} }
void _seedReactionsForPost(Post post) { void _seedReactionsForPost(Post post) {
print('DEBUG: _seedReactionsForPost for post ${post.id}');
print('DEBUG: post.reactions = ${post.reactions}');
print('DEBUG: post.myReactions = ${post.myReactions}');
if (post.reactions != null) { if (post.reactions != null) {
_reactionCountsByPost.putIfAbsent( _reactionCountsByPost.putIfAbsent(
post.id, post.id,
() => Map<String, int>.from(post.reactions!), () => Map<String, int>.from(post.reactions!),
); );
print('DEBUG: Seeded reaction counts: ${_reactionCountsByPost[post.id]}');
} }
if (post.myReactions != null) { if (post.myReactions != null) {
_myReactionsByPost.putIfAbsent(post.id, () => post.myReactions!.toSet()); _myReactionsByPost.putIfAbsent(post.id, () => post.myReactions!.toSet());
print('DEBUG: Seeded my reactions: ${_myReactionsByPost[post.id]}');
} }
if (post.reactionUsers != null) { if (post.reactionUsers != null) {
_reactionUsersByPost.putIfAbsent( _reactionUsersByPost.putIfAbsent(
@ -1020,19 +1046,12 @@ class _ThreadedConversationScreenState extends ConsumerState<ThreadedConversatio
} }
Map<String, int> _reactionCountsFor(Post post) { Map<String, int> _reactionCountsFor(Post post) {
// Debug: Check what we're getting from the post model
print('DEBUG: _reactionCountsFor for post ${post.id}');
print('DEBUG: post.reactions = ${post.reactions}');
print('DEBUG: _reactionCountsByPost[${post.id}] = ${_reactionCountsByPost[post.id]}');
// Prefer local state for immediate updates after toggle reactions // Prefer local state for immediate updates after toggle reactions
final localState = _reactionCountsByPost[post.id]; final localState = _reactionCountsByPost[post.id];
if (localState != null) { if (localState != null) {
print('DEBUG: Using local state: ${localState}');
return localState; return localState;
} }
// Fall back to post model if no local state // Fall back to post model if no local state
print('DEBUG: Using post.reactions: ${post.reactions}');
return post.reactions ?? {}; return post.reactions ?? {};
} }
@ -1063,22 +1082,16 @@ class _ThreadedConversationScreenState extends ConsumerState<ThreadedConversatio
final updatedCounts = response['reactions'] as Map<String, dynamic>?; final updatedCounts = response['reactions'] as Map<String, dynamic>?;
final updatedMine = response['my_reactions'] as List<dynamic>?; final updatedMine = response['my_reactions'] as List<dynamic>?;
print('DEBUG: Toggle reaction response: $response');
print('DEBUG: updatedCounts: $updatedCounts');
print('DEBUG: updatedMine: $updatedMine');
if (updatedCounts != null) { if (updatedCounts != null) {
setState(() { setState(() {
_reactionCountsByPost[postId] = updatedCounts _reactionCountsByPost[postId] = updatedCounts
.map((key, value) => MapEntry(key, value as int)); .map((key, value) => MapEntry(key, value as int));
print('DEBUG: Updated local reaction counts: ${_reactionCountsByPost[postId]}');
}); });
} }
if (updatedMine != null) { if (updatedMine != null) {
setState(() { setState(() {
_myReactionsByPost[postId] = _myReactionsByPost[postId] =
updatedMine.map((item) => item.toString()).toSet(); updatedMine.map((item) => item.toString()).toSet();
print('DEBUG: Updated local my reactions: ${_myReactionsByPost[postId]}');
}); });
} }
} catch (_) { } catch (_) {
@ -1161,4 +1174,19 @@ class _ThreadedConversationScreenState extends ConsumerState<ThreadedConversatio
if (trimmed.isEmpty) return 'S'; if (trimmed.isEmpty) return 'S';
return trimmed.characters.first.toUpperCase(); return trimmed.characters.first.toUpperCase();
} }
Future<void> _sharePost(Post post) async {
final handle = post.author?.handle ?? 'sojorn';
final text = '${post.body}\n\n— @$handle on sojorn';
try {
await Share.share(text);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Unable to share right now.')),
);
}
}
}
} }

View file

@ -29,7 +29,7 @@ class ProfileScreen extends ConsumerStatefulWidget {
String _resolveAvatar(String? url) { String _resolveAvatar(String? url) {
if (url == null || url.isEmpty) return ''; if (url == null || url.isEmpty) return '';
if (url.startsWith('http://') || url.startsWith('https://')) return url; if (url.startsWith('http://') || url.startsWith('https://')) return url;
return 'https://img.gosojorn.com/${url.replaceFirst(RegExp('^/'), '')}'; return 'https://img.sojorn.net/${url.replaceFirst(RegExp('^/'), '')}';
} }
class _ProfileScreenState extends ConsumerState<ProfileScreen> class _ProfileScreenState extends ConsumerState<ProfileScreen>

View file

@ -90,7 +90,7 @@ class _QuipsFeedScreenState extends ConsumerState<QuipsFeedScreen>
bool _isUserPaused = false; bool _isUserPaused = false;
int _lastRefreshToken = 0; int _lastRefreshToken = 0;
static const int _branchIndex = 2; static const int _branchIndex = 1;
static const int _pageSize = 8; static const int _pageSize = 8;
@override @override

View file

@ -1,4 +1,5 @@
import 'dart:async'; import 'dart:async';
import '../../models/profile.dart';
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
@ -32,13 +33,11 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
List<RecentSearch> recentSearches = []; List<RecentSearch> recentSearches = [];
int _searchEpoch = 0; int _searchEpoch = 0;
final Map<String, Future<Post>> _postFutures = {};
// Discovery State // Discovery State
bool _isDiscoveryLoading = false; bool _isDiscoveryLoading = false;
List<Post> _discoveryPosts = []; List<Post> _discoveryPosts = [];
static const Duration debounceDuration = Duration(milliseconds: 250); static const Duration debounceDuration = Duration(milliseconds: 300);
static const List<String> trendingTags = [ static const List<String> trendingTags = [
'safety', 'safety',
'wellness', 'wellness',
@ -155,7 +154,9 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
} }
debounceTimer = Timer(debounceDuration, () { debounceTimer = Timer(debounceDuration, () {
performSearch(query); if (query.length >= 2) {
performSearch(query);
}
}); });
} }
@ -170,9 +171,15 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
}); });
try { try {
print('[SearchScreen] Requesting search for: "$normalizedQuery"');
final apiService = ref.read(apiServiceProvider); final apiService = ref.read(apiServiceProvider);
final searchResults = await apiService.search(normalizedQuery); final searchResults = await apiService.search(normalizedQuery);
if (!mounted || requestId != _searchEpoch) return; if (!mounted || requestId != _searchEpoch) {
print('[SearchScreen] Request $requestId discarded (stale)');
return;
}
print('[SearchScreen] Results received. Users: ${searchResults.users.length}, Tags: ${searchResults.tags.length}, Posts: ${searchResults.posts.length}');
if (searchResults.users.isNotEmpty) { if (searchResults.users.isNotEmpty) {
await saveRecentSearch(RecentSearch( await saveRecentSearch(RecentSearch(
@ -196,6 +203,7 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
isLoading = false; isLoading = false;
}); });
} catch (e) { } catch (e) {
print('[SearchScreen] Search error: $e');
if (!mounted || requestId != _searchEpoch) return; if (!mounted || requestId != _searchEpoch) return;
setState(() { setState(() {
isLoading = false; isLoading = false;
@ -214,18 +222,6 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
focusNode.requestFocus(); focusNode.requestFocus();
} }
Future<Post> _getPostFuture(String postId) {
return _postFutures.putIfAbsent(postId, () {
final apiService = ref.read(apiServiceProvider);
return apiService.getPostById(postId);
});
}
void _retryPostLoad(String postId) {
_postFutures.remove(postId);
if (mounted) setState(() {});
}
void _openPostDetail(Post post) { void _openPostDetail(Post post) {
Navigator.of(context, rootNavigator: true).push( Navigator.of(context, rootNavigator: true).push(
MaterialPageRoute( MaterialPageRoute(
@ -588,84 +584,48 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
} }
Widget buildPostResultItem(SearchPost post) { Widget buildPostResultItem(SearchPost post) {
return FutureBuilder<Post>( // Convert SearchPost to minimal Post immediately
future: _getPostFuture(post.id), final minimalPost = Post(
builder: (context, snapshot) { id: post.id,
if (snapshot.connectionState == ConnectionState.waiting) { body: post.body,
return Container( authorId: post.authorId,
margin: const EdgeInsets.only(bottom: 12), createdAt: post.createdAt,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppTheme.cardSurface,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppTheme.egyptianBlue.withOpacity(0.2)),
),
child: Row(
children: [
SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: AppTheme.royalPurple,
),
),
const SizedBox(width: 12),
Text('Loading post...', style: AppTheme.bodyMedium),
],
),
);
}
if (snapshot.hasError || !snapshot.hasData) { // REQUIRED fields missing previously
return Container( status: PostStatus.active,
margin: const EdgeInsets.only(bottom: 12), detectedTone: ToneLabel.neutral,
padding: const EdgeInsets.all(16), contentIntegrityScore: 0.0,
decoration: BoxDecoration(
color: AppTheme.cardSurface,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppTheme.egyptianBlue.withOpacity(0.2)),
),
child: Row(
children: [
Icon(Icons.error_outline,
color: AppTheme.egyptianBlue.withOpacity(0.6)),
const SizedBox(width: 12),
Expanded(
child: Text(
'Unable to load post',
style: AppTheme.bodyMedium,
),
),
TextButton(
onPressed: () => _retryPostLoad(post.id),
child: Text('Retry',
style:
AppTheme.labelMedium.copyWith(color: AppTheme.royalPurple)),
),
],
),
);
}
final fullPost = snapshot.data!; author: Profile(
return ClipRRect( id: post.authorId,
handle: post.authorHandle,
displayName: post.authorDisplayName,
createdAt: DateTime.now(),
avatarUrl: null,
),
// Set defaults for rest
isLiked: false,
likeCount: 0,
commentCount: 0,
tags: [],
);
return ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Container(
margin: const EdgeInsets.only(bottom: 12),
decoration: BoxDecoration(
color: AppTheme.cardSurface,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
child: Container( border: Border.all(color: AppTheme.egyptianBlue.withOpacity(0.2)),
margin: const EdgeInsets.only(bottom: 12), ),
decoration: BoxDecoration( child: sojornPostCard(
color: AppTheme.cardSurface, post: minimalPost,
borderRadius: BorderRadius.circular(12), onTap: () => _openPostDetail(minimalPost),
border: Border.all(color: AppTheme.egyptianBlue.withOpacity(0.2)), onChain: () => _openChainComposer(minimalPost),
), // showActions removed (not supported)
child: sojornPostCard( ),
post: fullPost, ),
onTap: () => _openPostDetail(fullPost),
onChain: () => _openChainComposer(fullPost),
),
),
);
},
); );
} }
} }

View file

@ -943,17 +943,25 @@ class ApiService {
final sanitizedQuery = SecurityUtils.limitText(SecurityUtils.sanitizeText(query), maxLength: 100); final sanitizedQuery = SecurityUtils.limitText(SecurityUtils.sanitizeText(query), maxLength: 100);
if (!SecurityUtils.isValidInput(sanitizedQuery)) { if (!SecurityUtils.isValidInput(sanitizedQuery)) {
if (kDebugMode) print('[API] Invalid search query input: $query');
return SearchResults(users: [], tags: [], posts: []); return SearchResults(users: [], tags: [], posts: []);
} }
try { try {
if (kDebugMode) print('[API] Searching for: $sanitizedQuery');
final data = await callGoApi( final data = await callGoApi(
'/search', '/search',
method: 'GET', method: 'GET',
queryParams: {'q': sanitizedQuery}, queryParams: {'q': sanitizedQuery},
); );
// if (kDebugMode) print('[API] Search raw response: ${jsonEncode(data)}');
return SearchResults.fromJson(data); return SearchResults.fromJson(data);
} catch (_) { } catch (e, stack) {
if (kDebugMode) {
print('[API] Search failed for query: "$query"');
print('Error: $e');
print('Stack: $stack');
}
// Return empty results on error // Return empty results on error
return SearchResults(users: [], tags: [], posts: []); return SearchResults(users: [], tags: [], posts: []);
} }

View file

@ -505,13 +505,13 @@ class ImageUploadService {
// Fix Image URLs // Fix Image URLs
if (url.contains('/sojorn-media/')) { if (url.contains('/sojorn-media/')) {
final key = url.split('/sojorn-media/').last; final key = url.split('/sojorn-media/').last;
return 'https://img.gosojorn.com/$key'; return 'https://img.sojorn.net/$key';
} }
// Fix Video URLs // Fix Video URLs
if (url.contains('/sojorn-videos/')) { if (url.contains('/sojorn-videos/')) {
final key = url.split('/sojorn-videos/').last; final key = url.split('/sojorn-videos/').last;
return 'https://quips.gosojorn.com/$key'; return 'https://quips.sojorn.net/$key';
} }
return url; return url;

View file

@ -531,7 +531,7 @@ class NotificationService {
case 'thread_view': case 'thread_view':
case 'main_feed': case 'main_feed':
default: default:
navigator.context.go(AppRoutes.home); navigator.context.push('${AppRoutes.postPrefix}/$postId');
break; break;
} }
} }

View file

@ -28,7 +28,7 @@ class LinkHandler {
Uri? uri = Uri.tryParse(url.replaceFirst('sojorn://', 'sojorn://')); Uri? uri = Uri.tryParse(url.replaceFirst('sojorn://', 'sojorn://'));
// Normalize to https for query parsing if needed // Normalize to https for query parsing if needed
uri ??= uri ??=
Uri.tryParse(url.replaceFirst('sojorn://', 'https://gosojorn.com/')); Uri.tryParse(url.replaceFirst('sojorn://', 'https://sojorn.net/'));
final latParam = uri?.queryParameters['lat']; final latParam = uri?.queryParameters['lat'];
final longParam = uri?.queryParameters['long']; final longParam = uri?.queryParameters['long'];

View file

@ -7,7 +7,7 @@ class UrlLauncherHelper {
// List of known safe domains // List of known safe domains
static const List<String> _safeDomains = [ static const List<String> _safeDomains = [
'mp.ls', 'www.mp.ls', 'patrick.mp.ls' 'mp.ls', 'www.mp.ls', 'patrick.mp.ls'
'gosojorn.com', 'www.gosojorn.com' 'sojorn.net', 'www.sojorn.net'
'youtube.com', 'www.youtube.com', 'youtu.be', 'youtube.com', 'www.youtube.com', 'youtu.be',
'instagram.com', 'www.instagram.com', 'instagram.com', 'www.instagram.com',
'twitter.com', 'www.twitter.com', 'x.com', 'www.x.com', 'twitter.com', 'www.twitter.com', 'x.com', 'www.x.com',

View file

@ -70,12 +70,12 @@ class _SignedMediaImageState extends ConsumerState<SignedMediaImage> {
final host = uri.host.toLowerCase(); final host = uri.host.toLowerCase();
// Custom domain URLs are public and directly accessible - no signing needed // Custom domain URLs are public and directly accessible - no signing needed
if (host == 'img.gosojorn.com' || host == 'quips.gosojorn.com') { if (host == 'img.sojorn.net' || host == 'quips.sojorn.net') {
return false; return false;
} }
// Legacy: media.gosojorn.com might need signing depending on setup // Legacy: media.sojorn.net might need signing depending on setup
if (host == 'media.gosojorn.com') { if (host == 'media.sojorn.net') {
return true; return true;
} }

View file

@ -278,11 +278,13 @@ class _PostActionsState extends ConsumerState<PostActions> {
if (allowChain) if (allowChain)
ElevatedButton.icon( ElevatedButton.icon(
onPressed: widget.onChain, onPressed: widget.onChain,
icon: const Icon(Icons.reply, size: 18), icon: Icon(Icons.reply, size: 18, color: AppTheme.navyBlue),
label: const Text('Reply'), label: Text('Reply', style: TextStyle(color: AppTheme.navyBlue, fontWeight: FontWeight.w600)),
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.brightNavy, backgroundColor: AppTheme.navyBlue.withValues(alpha: 0.05),
foregroundColor: Colors.white, foregroundColor: AppTheme.navyBlue,
elevation: 0,
shadowColor: Colors.transparent,
minimumSize: const Size(0, 44), minimumSize: const Size(0, 44),
padding: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.symmetric(horizontal: 16),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(

View file

@ -14,7 +14,7 @@ import '../../routes/app_routes.dart';
String _resolveAvatarUrl(String? url) { String _resolveAvatarUrl(String? url) {
if (url == null || url.isEmpty) return ''; if (url == null || url.isEmpty) return '';
if (url.startsWith('http://') || url.startsWith('https://')) return url; if (url.startsWith('http://') || url.startsWith('https://')) return url;
return 'https://img.gosojorn.com/${url.replaceFirst(RegExp('^/'), '')}'; return 'https://img.sojorn.net/${url.replaceFirst(RegExp('^/'), '')}';
} }
/// Post header with author info and timestamp. /// Post header with author info and timestamp.

View file

@ -54,11 +54,11 @@ class ReactionsDisplay extends StatelessWidget {
return Row( return Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
if (onAddReaction != null) ...[
_ReactionAddButton(onTap: onAddReaction!),
if (reactionCounts.isNotEmpty) const SizedBox(width: 8),
],
if (reactionCounts.isNotEmpty) _buildTopReactionChip(), if (reactionCounts.isNotEmpty) _buildTopReactionChip(),
if (onAddReaction != null) ...[
if (reactionCounts.isNotEmpty) const SizedBox(width: 8),
_ReactionAddButton(onTap: onAddReaction!),
],
], ],
); );
} }
@ -95,6 +95,8 @@ class ReactionsDisplay extends StatelessWidget {
runSpacing: 8, runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center, crossAxisAlignment: WrapCrossAlignment.center,
children: [ children: [
if (onAddReaction != null)
_ReactionAddButton(onTap: onAddReaction!),
...sortedEntries.map((entry) { ...sortedEntries.map((entry) {
return _ReactionChip( return _ReactionChip(
reactionId: entry.key, reactionId: entry.key,
@ -105,8 +107,6 @@ class ReactionsDisplay extends StatelessWidget {
onLongPress: onAddReaction, onLongPress: onAddReaction,
); );
}), }),
if (onAddReaction != null)
_ReactionAddButton(onTap: onAddReaction!),
], ],
), ),
); );

View file

@ -5,4 +5,4 @@ echo Starting Sojorn on Chrome...
echo. echo.
flutter run -d chrome ^ flutter run -d chrome ^
--dart-define=API_BASE_URL=https://api.gosojorn.com/api/v1 --dart-define=API_BASE_URL=https://api.sojorn.net/api/v1

View file

@ -5,4 +5,4 @@ echo Starting Sojorn in development mode...
echo. echo.
flutter run ^ flutter run ^
--dart-define=API_BASE_URL=https://api.gosojorn.com/api/v1 --dart-define=API_BASE_URL=https://api.sojorn.net/api/v1

View file

@ -197,7 +197,7 @@ for _, origin := range allowedOrigins {
### Zero Downtime Approach ### Zero Downtime Approach
1. **Parallel Run**: Both Supabase and Go VPS running simultaneously 1. **Parallel Run**: Both Supabase and Go VPS running simultaneously
2. **DNS Update**: Point `api.gosojorn.com` to new VPS IP 2. **DNS Update**: Point `api.sojorn.net` to new VPS IP
3. **TTL Management**: Set DNS TTL to 300s before cutover 3. **TTL Management**: Set DNS TTL to 300s before cutover
4. **Monitoring**: Real-time log monitoring for errors 4. **Monitoring**: Real-time log monitoring for errors
@ -212,7 +212,7 @@ for _, origin := range allowedOrigins {
**DNS Switch:** **DNS Switch:**
```bash ```bash
# Update A record for api.gosojorn.com # Update A record for api.sojorn.net
# Monitor propagation # Monitor propagation
# Watch error rates # Watch error rates
``` ```
@ -223,7 +223,7 @@ for _, origin := range allowedOrigins {
journalctl -u sojorn-api -f journalctl -u sojorn-api -f
# Check error rates # Check error rates
curl -s https://api.gosojorn.com/health curl -s https://api.sojorn.net/health
# Validate data integrity # Validate data integrity
sudo -u postgres psql sojorn -c "SELECT COUNT(*) FROM users;" sudo -u postgres psql sojorn -c "SELECT COUNT(*) FROM users;"
@ -329,7 +329,7 @@ sudo -u postgres psql sojorn -c "SELECT COUNT(*) FROM users;"
### Emergency Rollback Procedure ### Emergency Rollback Procedure
1. **DNS Reversion**: Point `api.gosojorn.com` back to Supabase 1. **DNS Reversion**: Point `api.sojorn.net` back to Supabase
2. **Data Sync**: Restore any new data from Go backend to Supabase 2. **Data Sync**: Restore any new data from Go backend to Supabase
3. **Service Restart**: Restart Supabase Edge Functions 3. **Service Restart**: Restart Supabase Edge Functions
4. **Client Update**: Update Flutter app configuration if needed 4. **Client Update**: Update Flutter app configuration if needed

View file

@ -4,6 +4,8 @@
This guide consolidates all deployment, operations, and maintenance documentation for the Sojorn platform, covering infrastructure setup, deployment procedures, monitoring, and operational best practices. This guide consolidates all deployment, operations, and maintenance documentation for the Sojorn platform, covering infrastructure setup, deployment procedures, monitoring, and operational best practices.
All code updates need to be made locally, synced to git, pulled to the server and deployed. Do not directly edit files on the server.
--- ---
## Infrastructure Setup ## Infrastructure Setup
@ -136,19 +138,19 @@ FIREBASE_CREDENTIALS_FILE=/opt/sojorn/firebase-service-account.json
FIREBASE_WEB_VAPID_KEY=BNxS7_your_vapid_key_here FIREBASE_WEB_VAPID_KEY=BNxS7_your_vapid_key_here
# Storage # Storage
R2_ACCOUNT_ID=your-r2-account-id SENDER_API_TOKEN=eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJhdWQiOiIxIiwianRpIjoiNT>
R2_ACCESS_KEY_ID=your-access-key R2_ACCOUNT_ID=7041ca6e0f40307190dc2e65e2fb5e0f
R2_SECRET_ACCESS_KEY=your-secret-key R2_PUBLIC_BASE_URL=http://api.sojorn.net:8080/uploads
R2_BUCKET_NAME=sojorn-uploads R2_IMG_DOMAIN=img.sojorn.net
R2_PUBLIC_BASE_URL=https://pub-xxxxx.r2.dev R2_VID_DOMAIN=quips.sojorn.net
R2_IMG_DOMAIN=img.sojorn.com R2_API_TOKEN=oR7Vk0Realtx0D6SAGMuYA8pXopSoCYKv8t3JEuk
R2_VID_DOMAIN=vid.sojorn.com
# Email (Optional) # Email (Optional)
SMTP_HOST=smtp.gmail.com # Email / SendPulse
SMTP_HOST=smtp-pulse.com
SMTP_PORT=587 SMTP_PORT=587
SMTP_USER=noreply@sojorn.com SMTP_USER=patrickbritton3@gmail.com
SMTP_PASS=your-app-password SMTP_PASS=8s4jQBnAFTCXPNM
# Logging # Logging
LOG_LEVEL=info LOG_LEVEL=info
@ -245,7 +247,7 @@ Create `/etc/nginx/sites-available/sojorn-api`:
```nginx ```nginx
server { server {
listen 80; listen 80;
server_name api.gosojorn.com; server_name api.sojorn.net;
# Redirect to HTTPS # Redirect to HTTPS
return 301 https://$server_name$request_uri; return 301 https://$server_name$request_uri;
@ -253,11 +255,11 @@ server {
server { server {
listen 443 ssl http2; listen 443 ssl http2;
server_name api.gosojorn.com; server_name api.sojorn.net;
# SSL Configuration # SSL Configuration
ssl_certificate /etc/letsencrypt/live/api.gosojorn.com/fullchain.pem; ssl_certificate /etc/letsencrypt/live/api.sojorn.net/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/api.gosojorn.com/privkey.pem; ssl_certificate_key /etc/letsencrypt/live/api.sojorn.net/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3; ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-RSA-AES256-GCM-SHA512:DHE-RSA-AES256-GCM-SHA512:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES256-GCM-SHA384; ssl_ciphers ECDHE-RSA-AES256-GCM-SHA512:DHE-RSA-AES256-GCM-SHA512:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off; ssl_prefer_server_ciphers off;
@ -346,7 +348,7 @@ sudo systemctl reload nginx
sudo apt install certbot python3-certbot-nginx sudo apt install certbot python3-certbot-nginx
# Obtain certificate # Obtain certificate
sudo certbot --nginx -d api.gosojorn.com sudo certbot --nginx -d api.sojorn.net
# Test renewal # Test renewal
sudo certbot renew --dry-run sudo certbot renew --dry-run
@ -556,7 +558,7 @@ psql -h localhost -U sojorn_user -d sojorn
sudo systemctl start sojorn-api sudo systemctl start sojorn-api
# Verify recovery # Verify recovery
curl -f https://api.gosojorn.com/health curl -f https://api.sojorn.net/health
``` ```
#### File Recovery #### File Recovery
@ -939,7 +941,7 @@ sudo systemctl reload nginx
#### Problem: SSL handshake failed #### Problem: SSL handshake failed
```bash ```bash
# Test SSL configuration # Test SSL configuration
openssl s_client -connect api.gosojorn.com:443 openssl s_client -connect api.sojorn.net:443
# Check Nginx configuration # Check Nginx configuration
sudo nginx -t sudo nginx -t
@ -1003,7 +1005,7 @@ sudo systemctl reload nginx
sudo systemctl restart postgresql sudo systemctl restart postgresql
# Verify recovery # Verify recovery
curl -f https://api.gosojorn.com/health curl -f https://api.sojorn.net/health
``` ```
3. **Communication** 3. **Communication**

View file

@ -0,0 +1,95 @@
MIGRATION COMMANDS CHEAT SHEET
============================
STEP 0: EDIT LOCAL SYNC GLOBALLY
The first directive to to edit locally here, keep git updated fequently, then
grab the updates files to our VPS server and then built and restart.
------------
STEP 1: FLUTTER WEB DEPLOY (If applicable)
------------------------------------------
Ensure your flutter web build is updated and copied to /var/www/sojorn
flutter build web --release
scp -r build/web/* user@server:/var/www/sojorn/
STEP 2: NGINX CONFIGURATION
---------------------------
# 1. SSH into your VPS
ssh ...
# 2. Copy the new configs (upload them first or copy-paste)
# Assuming you uploaded sojorn_net.conf and legacy_redirect.conf to /tmp/
sudo cp /tmp/sojorn_net.conf /etc/nginx/sites-available/sojorn_net.conf
sudo cp /tmp/legacy_redirect.conf /etc/nginx/sites-available/legacy_redirect.conf
# 3. Enable new sites
sudo ln -s /etc/nginx/sites-available/sojorn_net.conf /etc/nginx/sites-enabled/
sudo ln -s /etc/nginx/sites-available/legacy_redirect.conf /etc/nginx/sites-enabled/
# 4. Disable old site (to avoid conflicts with the new legacy_redirect which claims the same domains)
# Check existing enabled sites
ls -l /etc/nginx/sites-enabled/
# Remove the old link (e.g., sojorn.conf or default)
sudo rm /etc/nginx/sites-enabled/sojorn.conf
# (Don't delete the actual file in sites-available, just the symlink)
# 5. Test Configuration (This might fail on SSL paths if legacy certs are missing, but they should be there)
sudo nginx -t
# 6. Reload Nginx (Users will briefly see unencrypted or default page for new domain until Certbot runs)
sudo systemctl reload nginx
STEP 3: SSL CERTIFICATES (CERTBOT)
----------------------------------
# Generate fresh certs for the NEW domain.
# --nginx plugin will automatically edit sojorn_net.conf to add SSL lines.
sudo certbot --nginx -d sojorn.net -d www.sojorn.net -d api.sojorn.net
# Follow the prompts. When asked about redirecting HTTP to HTTPS, choose "2: Redirect".
STEP 4: BACKEND & ENV
---------------------
# Update your .env file on the server
nano /opt/sojorn/.env
# CHANGE:
# CORS_ORIGINS=https://sojorn.net,https://api.sojorn.net,https://www.sojorn.net
# R2_PUBLIC_BASE_URL=https://img.sojorn.net
# Restart the backend service
sudo systemctl restart sojorn-api
STEP 5: VERIFICATION
--------------------
1. Visit https://sojorn.net -> Should show app.
2. Visit https://sojorn.net -> Should redirect to https://sojorn.net.
3. Check API: https://api.sojorn.net/api/v1/health (or similar).
STEP 6: EXTERNAL SERVICES CHECKLIST
-----------------------------------
These items fall outside the codebase but are CRITICAL for the migration:
1. CLOUDFLARE R2 (CORS)
- Go to Cloudflare Dashboard > R2 > [Your Bucket] > Settings > CORS Policy.
- Update allowed origins to include:
[ "https://sojorn.net", "https://www.sojorn.net", "http://localhost:*" ]
- If you don't do this, web image uploads will fail.
2. FIREBASE CONSOLE (Auth & Messaging)
- Go to Firebase Console > Authentication > Settings > Authorized Domains.
- ADD: sojorn.net
- ADD: api.sojorn.net
- You can remove gosojorn.com later.
3. GOOGLE CLOUD CONSOLE (If using Google Sign-In)
- APIs & Services > Credentials > OAuth 2.0 Client IDs.
- Add "https://sojorn.net" to Authorized JavaScript origins.
- Add "https://sojorn.net/auth.html" (or callback URI) to Authorized redirect URIs.
4. APPLE DEVELOPER PORTAL (If using Sign in with Apple)
- Certificates, Identifiers & Profiles > Service IDs.
- Update the "Domains and Subdomains" list for your Service ID to include sojorn.net.

View file

@ -91,7 +91,7 @@ flutter run -d <simulator-id>
### API Configuration ### API Configuration
The app connects to the production API at: The app connects to the production API at:
``` ```
https://api.gosojorn.com (or http://194.238.28.122:8080) https://api.sojorn.net (or http://194.238.28.122:8080)
``` ```
Configuration is in: `lib/config/api_config.dart` Configuration is in: `lib/config/api_config.dart`
@ -140,7 +140,7 @@ R2_SECRET_KEY=...
| `internal/handlers/user_handler.go` | Profile & social endpoints | | `internal/handlers/user_handler.go` | Profile & social endpoints |
| `internal/repository/post_repository.go` | Post database queries | | `internal/repository/post_repository.go` | Post database queries |
| `internal/repository/user_repository.go` | User database queries | | `internal/repository/user_repository.go` | User database queries |
aaa
--- ---
## Server Deployment ## Server Deployment

View file

@ -50,7 +50,7 @@ token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error)
**Verification**: **Verification**:
```bash ```bash
# Test JWT validation # Test JWT validation
curl -H "Authorization: Bearer <token>" https://api.gosojorn.com/health curl -H "Authorization: Bearer <token>" https://api.sojorn.net/health
``` ```
--- ---
@ -513,7 +513,7 @@ sudo certbot certificates
sudo nginx -t sudo nginx -t
# Check certificate expiry # Check certificate expiry
openssl x509 -in /etc/letsencrypt/live/api.gosojorn.com/cert.pem -text -noout | grep "Not After" openssl x509 -in /etc/letsencrypt/live/api.sojorn.net/cert.pem -text -noout | grep "Not After"
``` ```
**Solutions**: **Solutions**:
@ -527,8 +527,8 @@ sudo systemctl reload nginx
#### 2. Fix Nginx SSL Config #### 2. Fix Nginx SSL Config
```nginx ```nginx
ssl_certificate /etc/letsencrypt/live/api.gosojorn.com/fullchain.pem; ssl_certificate /etc/letsencrypt/live/api.sojorn.net/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/api.gosojorn.com/privkey.pem; ssl_certificate_key /etc/letsencrypt/live/api.sojorn.net/privkey.pem;
``` ```
### DNS Propagation Issues ### DNS Propagation Issues
@ -541,11 +541,11 @@ ssl_certificate_key /etc/letsencrypt/live/api.gosojorn.com/privkey.pem;
**Diagnostics**: **Diagnostics**:
```bash ```bash
# Check DNS resolution # Check DNS resolution
nslookup api.gosojorn.com nslookup api.sojorn.net
dig api.gosojorn.com dig api.sojorn.net
# Check propagation # Check propagation
for i in {1..10}; do echo "Attempt $i:"; dig api.gosojorn.com +short; sleep 30; done for i in {1..10}; do echo "Attempt $i:"; dig api.sojorn.net +short; sleep 30; done
``` ```
**Solutions**: **Solutions**:
@ -553,11 +553,11 @@ for i in {1..10}; do echo "Attempt $i:"; dig api.gosojorn.com +short; sleep 30;
#### 1. Verify DNS Records #### 1. Verify DNS Records
```bash ```bash
# Check A record # Check A record
dig api.gosojorn.com A dig api.sojorn.net A
# Check with multiple DNS servers # Check with multiple DNS servers
dig @8.8.8.8 api.gosojorn.com dig @8.8.8.8 api.sojorn.net
dig @1.1.1.1 api.gosojorn.com dig @1.1.1.1 api.sojorn.net
``` ```
#### 2. Reduce TTL Before Changes #### 2. Reduce TTL Before Changes
@ -581,7 +581,7 @@ sudo -u postgres psql -c "SELECT count(*) FROM users;"
# Network # Network
sudo netstat -tlnp | grep :8080 sudo netstat -tlnp | grep :8080
curl -I https://api.gosojorn.com/health curl -I https://api.sojorn.net/health
# Logs # Logs
sudo tail -f /var/log/nginx/access.log sudo tail -f /var/log/nginx/access.log
@ -635,7 +635,7 @@ sudo journalctl -u sojorn-api --since "1 hour ago" | grep -i error
3. **Verify Health**: 3. **Verify Health**:
```bash ```bash
curl https://api.gosojorn.com/health curl https://api.sojorn.net/health
``` ```
### Database Recovery ### Database Recovery

View file

@ -10,7 +10,7 @@ Complete guide to deploy Sojorn Flutter Web app to your VPS with Nginx.
- VPS with Ubuntu 20.04/22.04 (or Debian-based distro) - VPS with Ubuntu 20.04/22.04 (or Debian-based distro)
- Root or sudo access - Root or sudo access
- Domain name (gosojorn.com) pointed to your VPS IP - Domain name (sojorn.net) pointed to your VPS IP
- SSH access to your VPS - SSH access to your VPS
--- ---
@ -87,7 +87,7 @@ apt install certbot python3-certbot-nginx -y
**Important:** Make sure your domain DNS is already pointing to your VPS IP before running this. **Important:** Make sure your domain DNS is already pointing to your VPS IP before running this.
```bash ```bash
certbot --nginx -d gosojorn.com -d www.gosojorn.com certbot --nginx -d sojorn.net -d www.sojorn.net
``` ```
Follow the prompts: Follow the prompts:
@ -130,21 +130,21 @@ server {
listen 443 ssl http2; listen 443 ssl http2;
listen [::]:443 ssl http2; listen [::]:443 ssl http2;
server_name www.gosojorn.com; server_name www.sojorn.net;
ssl_certificate /etc/letsencrypt/live/gosojorn.com/fullchain.pem; ssl_certificate /etc/letsencrypt/live/sojorn.net/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/gosojorn.com/privkey.pem; ssl_certificate_key /etc/letsencrypt/live/sojorn.net/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf; include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
return 301 https://gosojorn.com$request_uri; return 301 https://sojorn.net$request_uri;
} }
# Main server block # Main server block
server { server {
listen 80; listen 80;
listen [::]:80; listen [::]:80;
server_name gosojorn.com; server_name sojorn.net;
# Redirect HTTP to HTTPS # Redirect HTTP to HTTPS
return 301 https://$server_name$request_uri; return 301 https://$server_name$request_uri;
@ -153,11 +153,11 @@ server {
server { server {
listen 443 ssl http2; listen 443 ssl http2;
listen [::]:443 ssl http2; listen [::]:443 ssl http2;
server_name gosojorn.com; server_name sojorn.net;
# SSL Configuration # SSL Configuration
ssl_certificate /etc/letsencrypt/live/gosojorn.com/fullchain.pem; ssl_certificate /etc/letsencrypt/live/sojorn.net/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/gosojorn.com/privkey.pem; ssl_certificate_key /etc/letsencrypt/live/sojorn.net/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf; include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
@ -327,8 +327,8 @@ chmod -R 755 /var/www/sojorn
## Part 8: Test Your Deployment ## Part 8: Test Your Deployment
1. Visit `https://gosojorn.com` - you should see your app 1. Visit `https://sojorn.net` - you should see your app
2. Test deep linking: `https://gosojorn.com/username` should route to a profile 2. Test deep linking: `https://sojorn.net/username` should route to a profile
3. Check SSL: Look for the padlock icon in the browser 3. Check SSL: Look for the padlock icon in the browser
--- ---
@ -508,10 +508,10 @@ certbot renew
You now have: You now have:
✅ Nginx web server installed and configured ✅ Nginx web server installed and configured
✅ SSL certificate for HTTPS ✅ SSL certificate for HTTPS
✅ Flutter Web app served at https://gosojorn.com ✅ Flutter Web app served at https://sojorn.net
✅ Deep linking support for URLs like /username ✅ Deep linking support for URLs like /username
✅ Gzip compression for better performance ✅ Gzip compression for better performance
✅ Proper security headers ✅ Proper security headers
✅ Caching for static assets ✅ Caching for static assets
Your app is now live and accessible at https://gosojorn.com! 🎉 Your app is now live and accessible at https://sojorn.net! 🎉

View file

@ -43,7 +43,7 @@ This document outlines the step-by-step process for cutover from Supabase to the
## Phase 4: Cutover (Zero Downtime Strategy) ## Phase 4: Cutover (Zero Downtime Strategy)
1. **Parallel Run**: Keep both Supabase and Go VPS running. 1. **Parallel Run**: Keep both Supabase and Go VPS running.
2. **DNS Update**: Point your API subdomain (e.g., `api.gosojorn.com`) to the new VPS IP. 2. **DNS Update**: Point your API subdomain (e.g., `api.sojorn.net`) to the new VPS IP.
3. **TTL Check**: Ensure DNS TTL is low (e.g., 300s) before starting. 3. **TTL Check**: Ensure DNS TTL is low (e.g., 300s) before starting.
4. **Monitor**: Watch logs for 4xx/5xx errors. 4. **Monitor**: Watch logs for 4xx/5xx errors.
```bash ```bash

View file

@ -9,7 +9,7 @@ The infrastructure for GoSojorn is **now fully functional and production-ready**
1. **CORS Resolved:** Fixed "Failed to fetch" errors by implementing dynamic origin matching (required for `AllowCredentials`). 1. **CORS Resolved:** Fixed "Failed to fetch" errors by implementing dynamic origin matching (required for `AllowCredentials`).
2. **Schema Complete:** Manually applied missing Signal Protocol migrations (`000002_e2ee_chat.up.sql`). 2. **Schema Complete:** Manually applied missing Signal Protocol migrations (`000002_e2ee_chat.up.sql`).
3. **Data Success:** Expanded seeder now provides ~300 posts and ~70 users, satisfying load-test requirements. 3. **Data Success:** Expanded seeder now provides ~300 posts and ~70 users, satisfying load-test requirements.
4. **Proxy Verified:** Nginx is correctly routing `api.gosojorn.com` to the Go service. 4. **Proxy Verified:** Nginx is correctly routing `api.sojorn.net` to the Go service.
## Phase 1: Infrastructure & Environment Integrity ## Phase 1: Infrastructure & Environment Integrity
- **Service Health:** - **Service Health:**
@ -51,4 +51,4 @@ The infrastructure for GoSojorn is **now fully functional and production-ready**
- **Status:** Stress test threshold MET. - **Status:** Stress test threshold MET.
## Final Verdict ## Final Verdict
The migration from Supabase to GoSojorn is **SUCCESSFUL**. The system is stable, the data is migrated/seeded, and the primary blocker (CORS) is removed. The Supabase instance can be safely paused after final client redirection to `api.gosojorn.com`. The migration from Supabase to GoSojorn is **SUCCESSFUL**. The system is stable, the data is migrated/seeded, and the primary blocker (CORS) is removed. The Supabase instance can be safely paused after final client redirection to `api.sojorn.net`.

View file

@ -6,8 +6,8 @@ All backend configuration is done. Images should now work properly!
### What Was Configured: ### What Was Configured:
1. ✅ **Custom Domain Connected**: `media.gosojorn.com` → R2 bucket `sojorn-media` 1. ✅ **Custom Domain Connected**: `media.sojorn.net` → R2 bucket `sojorn-media`
2. ✅ **Environment Variable Set**: `R2_PUBLIC_URL=https://media.gosojorn.com` 2. ✅ **Environment Variable Set**: `R2_PUBLIC_URL=https://media.sojorn.net`
3. ✅ **Edge Function Deployed**: Updated `upload-image` function using custom domain 3. ✅ **Edge Function Deployed**: Updated `upload-image` function using custom domain
4. ✅ **DNS Verified**: Domain resolving to Cloudflare CDN 4. ✅ **DNS Verified**: Domain resolving to Cloudflare CDN
5. ✅ **API Queries Fixed**: All post queries include `image_url` field 5. ✅ **API Queries Fixed**: All post queries include `image_url` field
@ -28,7 +28,7 @@ In the app:
**Expected behavior**: **Expected behavior**:
- Image uploads successfully - Image uploads successfully
- Post appears in feed with image visible - Post appears in feed with image visible
- Image URL format: `https://media.gosojorn.com/{uuid}.jpg` - Image URL format: `https://media.sojorn.net/{uuid}.jpg`
**If it works**: Images will now display everywhere (feed, profiles, chains) ✅ **If it works**: Images will now display everywhere (feed, profiles, chains) ✅
@ -43,7 +43,7 @@ ORDER BY created_at DESC
LIMIT 5; LIMIT 5;
``` ```
Expected format: `https://media.gosojorn.com/[uuid].[ext]` Expected format: `https://media.sojorn.net/[uuid].[ext]`
## Troubleshooting ## Troubleshooting
@ -65,7 +65,7 @@ Look for:
After uploading an image, copy its URL from the database and test: After uploading an image, copy its URL from the database and test:
```bash ```bash
curl -I https://media.gosojorn.com/[filename-from-db] curl -I https://media.sojorn.net/[filename-from-db]
``` ```
Should return `HTTP/1.1 200 OK` or `HTTP/2 200` Should return `HTTP/1.1 200 OK` or `HTTP/2 200`
@ -129,7 +129,7 @@ Complete guides available:
| API Queries | ✅ Include `image_url` field | | API Queries | ✅ Include `image_url` field |
| Flutter Model | ✅ Post model parses `image_url` | | Flutter Model | ✅ Post model parses `image_url` |
| Widget Display | ✅ PostItem widget shows images | | Widget Display | ✅ PostItem widget shows images |
| Custom Domain | ✅ `media.gosojorn.com` connected | | Custom Domain | ✅ `media.sojorn.net` connected |
**Ready to test!** 🚀 **Ready to test!** 🚀

View file

@ -253,7 +253,7 @@ Available keys: [id, body, created_at, tone_label, allow_chain,
**Other feed response (has image_url):** **Other feed response (has image_url):**
``` ```
DEBUG Post.fromJson: Found image_url in JSON: https://media.gosojorn.com/88a7cc72-... DEBUG Post.fromJson: Found image_url in JSON: https://media.sojorn.net/88a7cc72-...
Available keys: [id, body, author_id, category_id, tone_label, cis_score, Available keys: [id, body, author_id, category_id, tone_label, cis_score,
status, created_at, edited_at, deleted_at, allow_chain, status, created_at, edited_at, deleted_at, allow_chain,
chain_parent_id, image_url, chain_parent, metrics, author] chain_parent_id, image_url, chain_parent, metrics, author]
@ -271,9 +271,9 @@ I/flutter: PostMedia: post.imageUrl = null
### After Fix ### After Fix
``` ```
I/flutter: DEBUG Post.fromJson: Found image_url in JSON: https://media.gosojorn.com/88a7cc72-... I/flutter: DEBUG Post.fromJson: Found image_url in JSON: https://media.sojorn.net/88a7cc72-...
I/flutter: PostMedia: post.imageUrl = https://media.gosojorn.com/88a7cc72-... I/flutter: PostMedia: post.imageUrl = https://media.sojorn.net/88a7cc72-...
I/flutter: PostMedia: SHOWING IMAGE for https://media.gosojorn.com/88a7cc72-... I/flutter: PostMedia: SHOWING IMAGE for https://media.sojorn.net/88a7cc72-...
I/flutter: PostMedia: Image loading... 8899 / 275401 I/flutter: PostMedia: Image loading... 8899 / 275401
I/flutter: PostMedia: Image LOADED successfully I/flutter: PostMedia: Image LOADED successfully
``` ```
@ -291,7 +291,7 @@ npx supabase functions deploy feed-sojorn feed-personal --no-verify-jwt
### Image Upload Flow (Already Working) ### Image Upload Flow (Already Working)
1. User selects image in `ComposeScreen` 1. User selects image in `ComposeScreen`
2. Image uploaded via `ImageUploadService.uploadImage()` to Cloudflare R2 2. Image uploaded via `ImageUploadService.uploadImage()` to Cloudflare R2
3. Returns public URL: `https://media.gosojorn.com/{uuid}.jpg` 3. Returns public URL: `https://media.sojorn.net/{uuid}.jpg`
4. URL sent to `publish-post` edge function 4. URL sent to `publish-post` edge function
5. Saved to `posts.image_url` column 5. Saved to `posts.image_url` column

View file

@ -4,9 +4,9 @@
- **R2 Bucket**: `sojorn-media` - **R2 Bucket**: `sojorn-media`
- **Account ID**: `7041ca6e0f40307190dc2e65e2fb5e0f` - **Account ID**: `7041ca6e0f40307190dc2e65e2fb5e0f`
- **Custom Domain**: `media.gosojorn.com` - **Custom Domain**: `media.sojorn.net`
- **Upload URL**: `https://7041ca6e0f40307190dc2e65e2fb5e0f.r2.cloudflarestorage.com/sojorn-media` - **Upload URL**: `https://7041ca6e0f40307190dc2e65e2fb5e0f.r2.cloudflarestorage.com/sojorn-media`
- **Public URL**: `https://media.gosojorn.com` - **Public URL**: `https://media.sojorn.net`
## Quick Test ## Quick Test
@ -15,11 +15,11 @@
Go to: https://dash.cloudflare.com → R2 → `sojorn-media` bucket → Settings Go to: https://dash.cloudflare.com → R2 → `sojorn-media` bucket → Settings
Under "Custom Domains", you should see: Under "Custom Domains", you should see:
- ✅ `media.gosojorn.com` with status "Active" - ✅ `media.sojorn.net` with status "Active"
If not connected: If not connected:
1. Click "Connect Domain" 1. Click "Connect Domain"
2. Enter: `media.gosojorn.com` 2. Enter: `media.sojorn.net`
3. Wait 1-2 minutes for activation 3. Wait 1-2 minutes for activation
### 2. Test Upload in App ### 2. Test Upload in App
@ -47,14 +47,14 @@ ORDER BY created_at DESC
LIMIT 1; LIMIT 1;
``` ```
**Expected URL format**: `https://media.gosojorn.com/[uuid].[ext]` **Expected URL format**: `https://media.sojorn.net/[uuid].[ext]`
### 4. Test URL Directly ### 4. Test URL Directly
Copy the image_url from database and test in browser or curl: Copy the image_url from database and test in browser or curl:
```bash ```bash
curl -I https://media.gosojorn.com/[filename-from-database] curl -I https://media.sojorn.net/[filename-from-database]
``` ```
**Expected response**: `HTTP/2 200 OK` **Expected response**: `HTTP/2 200 OK`
@ -91,7 +91,7 @@ Required secrets:
| Issue | Cause | Solution | | Issue | Cause | Solution |
|-------|-------|----------| |-------|-------|----------|
| Upload fails with 401 | Invalid R2 credentials | Check R2_ACCESS_KEY and R2_SECRET_KEY | | Upload fails with 401 | Invalid R2 credentials | Check R2_ACCESS_KEY and R2_SECRET_KEY |
| Upload succeeds but image 404 | Domain not connected | Connect media.gosojorn.com to bucket | | Upload succeeds but image 404 | Domain not connected | Connect media.sojorn.net to bucket |
| "Missing R2_PUBLIC_URL" | Secret not set/propagated | Wait 2 minutes, redeploy function | | "Missing R2_PUBLIC_URL" | Secret not set/propagated | Wait 2 minutes, redeploy function |
| Image loads slowly | Not cached | Normal for first load, subsequent loads cached | | Image loads slowly | Not cached | Normal for first load, subsequent loads cached |
@ -100,7 +100,7 @@ Required secrets:
**Upload Flow**: **Upload Flow**:
1. User selects image → App processes/filters 1. User selects image → App processes/filters
2. App uploads to edge function → Edge function uploads to R2 2. App uploads to edge function → Edge function uploads to R2
3. Edge function returns: `https://media.gosojorn.com/[uuid].jpg` 3. Edge function returns: `https://media.sojorn.net/[uuid].jpg`
4. App saves post with image_url to database 4. App saves post with image_url to database
5. Feed queries posts with image_url 5. Feed queries posts with image_url
6. PostItem widget displays image 6. PostItem widget displays image