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
.profile
sojorn_docs/SOJORN_ARCHITECTURE.md

View file

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

View file

@ -1,7 +1,7 @@
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_VIDEO_DOMAIN = (Deno.env.get("CUSTOM_VIDEO_DOMAIN") ?? "https://quips.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.sojorn.net").trim();
const DEFAULT_BUCKET_NAME = "sojorn-media";
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 {
const url = new URL(trimmed);
// Handle legacy media.gosojorn.com URLs
if (url.hostname === 'media.gosojorn.com') {
// Handle legacy media.sojorn.net URLs
if (url.hostname === 'media.sojorn.net') {
const key = decodeURIComponent(url.pathname);
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 { 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 = {
'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 { 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) => {
if (req.method === 'OPTIONS') {

View file

@ -12,7 +12,7 @@
import { serve } from 'https://deno.land/std@0.177.0/http/server.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) => {
if (req.method === 'OPTIONS') {

View file

@ -2,7 +2,7 @@
import { serve } from 'https://deno.land/std@0.168.0/http/server.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 = {
'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 signedUrl = await trySignR2Url(transformedTarget, expiresIn);

View file

@ -26,7 +26,7 @@ serve(async (req) => {
if (req.method === 'OPTIONS') {
return new Response(null, {
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-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 ALLOWED_ORIGIN = Deno.env.get('ALLOWED_ORIGIN') || 'https://gosojorn.com';
const ALLOWED_ORIGIN = Deno.env.get('ALLOWED_ORIGIN') || 'https://sojorn.net';
const corsHeaders = {
'Access-Control-Allow-Origin': ALLOWED_ORIGIN,

View file

@ -49,7 +49,7 @@
</div>
<div class="flex items-center gap-6">
<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>
</div>
</nav>
@ -119,8 +119,8 @@
</div>
<p class="mt-6 text-sm text-gray-500">
Join the waitlist by emailing <a href="mailto:waitlist@gosojorn.com"
class="text-egyptianBlue underline">waitlist@gosojorn.com</a>
Join the waitlist by emailing <a href="mailto:waitlist@sojorn.net"
class="text-egyptianBlue underline">waitlist@sojorn.net</a>
</p>
</div>
</main>

View file

@ -73,8 +73,8 @@
you leave, you leave. We do not retain hidden profiles.</p>
<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"
class="text-egyptianBlue underline">privacy@gosojorn.com</a>.</p>
<p class="mb-4">For privacy concerns: <a href="mailto:privacy@sojorn.net"
class="text-egyptianBlue underline">privacy@sojorn.net</a>.</p>
</div>
</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
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%';
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%';
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%';
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%';
-- Fix sojorn-videos URLs (for quips) to quips.gosojorn.com
-- Fix sojorn-videos URLs (for quips) to quips.sojorn.net
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%';
-- Fix the one edge case where image_url contains a video URL
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%';
-- Verify after

View file

@ -3,7 +3,7 @@
-- 1. Fix image URLs that are just filenames (no domain)
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
AND image_url != ''
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)
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
AND video_url != ''
AND video_url NOT LIKE 'http%'
@ -19,7 +19,7 @@ WHERE video_url IS NOT NULL
-- 3. Fix thumbnail URLs that are just filenames
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
AND thumbnail_url != ''
AND thumbnail_url NOT LIKE 'http%'
@ -27,7 +27,7 @@ WHERE thumbnail_url IS NOT NULL
-- 4. Fix profile avatars that are just filenames
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
AND avatar_url != ''
AND avatar_url NOT LIKE 'http%'
@ -35,7 +35,7 @@ WHERE avatar_url IS NOT NULL
-- 5. Fix profile covers that are just filenames
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
AND cover_url != ''
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
SELECT 'Non-standard image URLs' as type, image_url FROM posts
WHERE image_url IS NOT NULL
AND image_url NOT LIKE 'https://img.gosojorn.com/%'
AND image_url NOT LIKE 'https://quips.gosojorn.com/%'
AND image_url NOT LIKE 'https://img.sojorn.net/%'
AND image_url NOT LIKE 'https://quips.sojorn.net/%'
LIMIT 5;
SELECT 'Non-standard video URLs' as type, video_url FROM posts
WHERE video_url IS NOT NULL
AND video_url NOT LIKE 'https://img.gosojorn.com/%'
AND video_url NOT LIKE 'https://quips.gosojorn.com/%'
AND video_url NOT LIKE 'https://img.sojorn.net/%'
AND video_url NOT LIKE 'https://quips.sojorn.net/%'
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 {
listen 80;
server_name api.gosojorn.com;
server_name api.sojorn.net;
location / {
proxy_pass http://localhost:8080;
@ -12,7 +12,7 @@ server {
server {
listen 80;
server_name gosojorn.com;
server_name sojorn.net;
root /var/www/sojorn;
index index.html;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -5,19 +5,24 @@ class ApiConfig {
static String _computeBaseUrl() {
final raw = const String.fromEnvironment(
'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
// 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(
'http://api.gosojorn.com:8080',
'https://api.gosojorn.com',
'http://api.sojorn.net:8080',
'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://')) {
return 'https://${raw.substring('http://'.length)}';
}

View file

@ -2,14 +2,19 @@ import 'profile.dart';
/// Types of notifications
enum NotificationType {
appreciate, // Someone appreciated your post
chain, // Someone chained your post
follow, // Someone followed you
follow_request, // Someone requested to follow you
new_follower, // Someone followed you (public or approved)
request_accepted, // Someone accepted your follow request
comment, // Someone commented on your post
mention, // Someone mentioned you
like, // Someone liked your post
comment, // Someone commented on your post
reply, // Someone replied to your post (chained)
mention, // Someone mentioned you
follow, // Someone followed you
follow_request, // Someone requested to follow you
follow_accepted, // Someone accepted your follow request
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
@ -115,22 +120,32 @@ class AppNotification {
String get message {
final actorName = actor?.displayName ?? 'Someone';
switch (type) {
case NotificationType.appreciate:
return '$actorName appreciated your post';
case NotificationType.chain:
return '$actorName chained your post';
case NotificationType.like:
return '$actorName liked your post';
case NotificationType.reply:
return '$actorName replied to your post';
case NotificationType.follow:
return '$actorName started following you';
case NotificationType.follow_request:
return '$actorName requested to follow you';
case NotificationType.new_follower:
return '$actorName followed you';
case NotificationType.request_accepted:
case NotificationType.follow_accepted:
return '$actorName accepted your follow request';
case NotificationType.comment:
return '$actorName commented on your post';
case NotificationType.mention:
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) {
return SearchUser(
id: json['id'] as String,
username: json['username'] as String,
displayName: json['display_name'] as String? ?? json['displayName'] as String? ?? json['username'] as String,
id: json['id'] as String? ?? '',
username: (json['username'] as String?) ?? (json['handle'] as String?) ?? 'unknown',
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?,
harmonyTier: json['harmony_tier'] as String? ?? json['harmonyTier'] as String? ?? 'new',
);
@ -81,12 +81,15 @@ class SearchPost {
});
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(
id: json['id'] as String,
body: json['body'] as String,
authorId: json['author_id'] as String,
authorHandle: json['author_handle'] as String,
authorDisplayName: json['author_display_name'] as String,
authorId: json['author_id'] as String? ?? authorJson?['id'] as String? ?? '',
authorHandle: json['author_handle'] as String? ?? authorJson?['handle'] as String? ?? 'unknown',
authorDisplayName: json['author_display_name'] as String? ?? authorJson?['display_name'] as String? ?? 'Unknown',
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/discover/discover_screen.dart';
import '../screens/secure_chat/secure_chat_full_screen.dart';
import '../screens/post/threaded_conversation_screen.dart';
/// App routing config (GoRouter).
class AppRoutes {
@ -65,6 +66,13 @@ class AppRoutes {
parentNavigatorKey: rootNavigatorKey,
builder: (_, __) => const SecureChatFullScreen(),
),
GoRoute(
path: '$postPrefix/:id',
parentNavigatorKey: rootNavigatorKey,
builder: (_, state) => ThreadedConversationScreen(
rootPostId: state.pathParameters['id'] ?? '',
),
),
StatefulShellRoute.indexedStack(
builder: (context, state, navigationShell) => AuthGate(
authenticatedChild: HomeShell(navigationShell: navigationShell),
@ -81,16 +89,16 @@ class AppRoutes {
StatefulShellBranch(
routes: [
GoRoute(
path: '/discover',
builder: (_, __) => const DiscoverScreen(),
path: quips,
builder: (_, __) => const QuipsFeedScreen(),
),
],
),
StatefulShellBranch(
routes: [
GoRoute(
path: quips,
builder: (_, __) => const QuipsFeedScreen(),
path: '/beacon',
builder: (_, __) => const BeaconScreen(),
),
],
),
@ -169,29 +177,29 @@ class AppRoutes {
}
/// Get shareable URL for a user profile
/// Returns: https://gosojorn.com/u/username
/// Returns: https://sojorn.net/u/username
static String getProfileUrl(
String username, {
String baseUrl = 'https://gosojorn.com',
String baseUrl = 'https://sojorn.net',
}) {
return '$baseUrl/u/$username';
}
/// Get shareable URL for a post (future implementation)
/// Returns: https://gosojorn.com/p/postid
/// Returns: https://sojorn.net/p/postid
static String getPostUrl(
String postId, {
String baseUrl = 'https://gosojorn.com',
String baseUrl = 'https://sojorn.net',
}) {
return '$baseUrl/p/$postId';
}
/// Get shareable URL for a beacon location
/// Returns: https://gosojorn.com/beacon?lat=...&long=...
/// Returns: https://sojorn.net/beacon?lat=...&long=...
static String getBeaconUrl(
double lat,
double long, {
String baseUrl = 'https://gosojorn.com',
String baseUrl = 'https://sojorn.net',
}) {
return '$baseUrl/beacon?lat=${lat.toStringAsFixed(6)}&long=${long.toStringAsFixed(6)}';
}

View file

@ -1,4 +1,5 @@
import 'dart:async';
import '../../models/profile.dart';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@ -81,9 +82,8 @@ class _DiscoverScreenState extends ConsumerState<DiscoverScreen> {
DiscoverData? discoverData;
List<RecentSearch> recentSearches = [];
int _searchEpoch = 0;
final Map<String, Future<Post>> _postFutures = {};
static const Duration debounceDuration = Duration(milliseconds: 250);
static const Duration debounceDuration = Duration(milliseconds: 300);
@override
void initState() {
@ -186,7 +186,9 @@ class _DiscoverScreenState extends ConsumerState<DiscoverScreen> {
}
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) {
Navigator.of(context, rootNavigator: true).push(
MaterialPageRoute(
@ -668,76 +658,35 @@ class _DiscoverScreenState extends ConsumerState<DiscoverScreen> {
}
Widget _buildPostResultItem(SearchPost post) {
return FutureBuilder<Post>(
future: _getPostFuture(post.id),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return Container(
margin: const EdgeInsets.only(bottom: 12),
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),
],
),
);
}
// Convert SearchPost to minimal Post immediately
final minimalPost = Post(
id: post.id,
body: post.body,
authorId: post.authorId,
createdAt: post.createdAt,
status: PostStatus.active,
detectedTone: ToneLabel.neutral,
contentIntegrityScore: 0.0,
author: Profile(
id: post.authorId,
handle: post.authorHandle,
displayName: post.authorDisplayName,
createdAt: DateTime.now(),
avatarUrl: null,
),
isLiked: false,
likeCount: 0,
commentCount: 0,
tags: [],
);
if (snapshot.hasError || !snapshot.hasData) {
return Container(
margin: const EdgeInsets.only(bottom: 12),
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: [
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),
),
);
},
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: sojornPostCard(
post: minimalPost,
onTap: () => _openPostDetail(minimalPost),
onChain: () => _openChainComposer(minimalPost),
),
);
}
}

View file

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

View file

@ -243,8 +243,7 @@ class _NotificationsScreenState extends ConsumerState<NotificationsScreen> {
// Navigate based on notification type
switch (notification.type) {
case NotificationType.follow:
case NotificationType.new_follower:
case NotificationType.request_accepted:
case NotificationType.follow_accepted:
// Navigate to the follower's profile
if (notification.actor != null) {
Navigator.of(context).push(
@ -255,9 +254,9 @@ class _NotificationsScreenState extends ConsumerState<NotificationsScreen> {
}
break;
case NotificationType.appreciate:
case NotificationType.like:
case NotificationType.comment:
case NotificationType.chain:
case NotificationType.reply:
case NotificationType.mention:
// Fetch the post and navigate to post detail
if (notification.postId != null) {
@ -284,8 +283,18 @@ class _NotificationsScreenState extends ConsumerState<NotificationsScreen> {
}
}
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:
break;
default:
break;
}
}
@ -492,20 +501,19 @@ class _NotificationItem extends StatelessWidget {
Color iconColor;
switch (notification.type) {
case NotificationType.appreciate:
case NotificationType.like:
iconData = Icons.favorite;
iconColor = AppTheme.brightNavy;
break;
case NotificationType.chain:
case NotificationType.reply:
iconData = Icons.subdirectory_arrow_right;
iconColor = AppTheme.royalPurple;
break;
case NotificationType.follow:
case NotificationType.new_follower:
iconData = Icons.person_add;
iconColor = AppTheme.ksuPurple;
break;
case NotificationType.request_accepted:
case NotificationType.follow_accepted:
iconData = Icons.check_circle;
iconColor = AppTheme.brightNavy;
break;
@ -521,6 +529,18 @@ class _NotificationItem extends StatelessWidget {
iconData = Icons.person_add;
iconColor = AppTheme.ksuPurple;
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(

View file

@ -13,6 +13,9 @@ import '../../theme/app_theme.dart';
import '../../widgets/post/interactive_reply_block.dart';
import '../../widgets/media/signed_media_image.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 {
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),
label: const Text('Reply'),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.brightNavy,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 14),
backgroundColor: AppTheme.navyBlue.withValues(alpha: 0.05),
foregroundColor: AppTheme.navyBlue,
elevation: 0,
shadowColor: Colors.transparent,
minimumSize: const Size(0, 44),
padding: const EdgeInsets.symmetric(horizontal: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
@ -594,13 +624,14 @@ class _ThreadedConversationScreenState extends ConsumerState<ThreadedConversatio
),
const SizedBox(width: 12),
IconButton(
onPressed: () => _toggleLike(post),
onPressed: () => _sharePost(post),
icon: Icon(
isLiked ? Icons.favorite : Icons.favorite_border,
color: isLiked ? Colors.red : AppTheme.textSecondary,
Icons.share_outlined,
color: AppTheme.textSecondary,
),
style: IconButton.styleFrom(
backgroundColor: AppTheme.navyBlue.withValues(alpha: 0.08),
backgroundColor: AppTheme.navyBlue.withValues(alpha: 0.05),
minimumSize: const Size(44, 44),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
@ -614,7 +645,8 @@ class _ThreadedConversationScreenState extends ConsumerState<ThreadedConversatio
color: isSaved ? AppTheme.brightNavy : AppTheme.textSecondary,
),
style: IconButton.styleFrom(
backgroundColor: AppTheme.navyBlue.withValues(alpha: 0.08),
backgroundColor: AppTheme.navyBlue.withValues(alpha: 0.05),
minimumSize: const Size(44, 44),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
@ -996,20 +1028,14 @@ class _ThreadedConversationScreenState extends ConsumerState<ThreadedConversatio
}
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) {
_reactionCountsByPost.putIfAbsent(
post.id,
() => Map<String, int>.from(post.reactions!),
);
print('DEBUG: Seeded reaction counts: ${_reactionCountsByPost[post.id]}');
}
if (post.myReactions != null) {
_myReactionsByPost.putIfAbsent(post.id, () => post.myReactions!.toSet());
print('DEBUG: Seeded my reactions: ${_myReactionsByPost[post.id]}');
}
if (post.reactionUsers != null) {
_reactionUsersByPost.putIfAbsent(
@ -1020,19 +1046,12 @@ class _ThreadedConversationScreenState extends ConsumerState<ThreadedConversatio
}
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
final localState = _reactionCountsByPost[post.id];
if (localState != null) {
print('DEBUG: Using local state: ${localState}');
return localState;
}
// Fall back to post model if no local state
print('DEBUG: Using post.reactions: ${post.reactions}');
return post.reactions ?? {};
}
@ -1063,22 +1082,16 @@ class _ThreadedConversationScreenState extends ConsumerState<ThreadedConversatio
final updatedCounts = response['reactions'] as Map<String, 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) {
setState(() {
_reactionCountsByPost[postId] = updatedCounts
.map((key, value) => MapEntry(key, value as int));
print('DEBUG: Updated local reaction counts: ${_reactionCountsByPost[postId]}');
});
}
if (updatedMine != null) {
setState(() {
_myReactionsByPost[postId] =
updatedMine.map((item) => item.toString()).toSet();
print('DEBUG: Updated local my reactions: ${_myReactionsByPost[postId]}');
});
}
} catch (_) {
@ -1161,4 +1174,19 @@ class _ThreadedConversationScreenState extends ConsumerState<ThreadedConversatio
if (trimmed.isEmpty) return 'S';
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) {
if (url == null || url.isEmpty) return '';
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>

View file

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

View file

@ -1,4 +1,5 @@
import 'dart:async';
import '../../models/profile.dart';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@ -31,14 +32,12 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
SearchResults? results;
List<RecentSearch> recentSearches = [];
int _searchEpoch = 0;
final Map<String, Future<Post>> _postFutures = {};
// Discovery State
bool _isDiscoveryLoading = false;
List<Post> _discoveryPosts = [];
static const Duration debounceDuration = Duration(milliseconds: 250);
static const Duration debounceDuration = Duration(milliseconds: 300);
static const List<String> trendingTags = [
'safety',
'wellness',
@ -155,7 +154,9 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
}
debounceTimer = Timer(debounceDuration, () {
performSearch(query);
if (query.length >= 2) {
performSearch(query);
}
});
}
@ -170,9 +171,15 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
});
try {
print('[SearchScreen] Requesting search for: "$normalizedQuery"');
final apiService = ref.read(apiServiceProvider);
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) {
await saveRecentSearch(RecentSearch(
@ -196,6 +203,7 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
isLoading = false;
});
} catch (e) {
print('[SearchScreen] Search error: $e');
if (!mounted || requestId != _searchEpoch) return;
setState(() {
isLoading = false;
@ -214,18 +222,6 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
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) {
Navigator.of(context, rootNavigator: true).push(
MaterialPageRoute(
@ -588,85 +584,49 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
}
Widget buildPostResultItem(SearchPost post) {
return FutureBuilder<Post>(
future: _getPostFuture(post.id),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return Container(
margin: const EdgeInsets.only(bottom: 12),
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) {
return Container(
margin: const EdgeInsets.only(bottom: 12),
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: [
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 ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Container(
margin: const EdgeInsets.only(bottom: 12),
decoration: BoxDecoration(
color: AppTheme.cardSurface,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppTheme.egyptianBlue.withOpacity(0.2)),
),
child: sojornPostCard(
post: fullPost,
onTap: () => _openPostDetail(fullPost),
onChain: () => _openChainComposer(fullPost),
),
),
);
},
// Convert SearchPost to minimal Post immediately
final minimalPost = Post(
id: post.id,
body: post.body,
authorId: post.authorId,
createdAt: post.createdAt,
// REQUIRED fields missing previously
status: PostStatus.active,
detectedTone: ToneLabel.neutral,
contentIntegrityScore: 0.0,
author: Profile(
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),
border: Border.all(color: AppTheme.egyptianBlue.withOpacity(0.2)),
),
child: sojornPostCard(
post: minimalPost,
onTap: () => _openPostDetail(minimalPost),
onChain: () => _openChainComposer(minimalPost),
// showActions removed (not supported)
),
),
);
}
}

View file

@ -943,17 +943,25 @@ class ApiService {
final sanitizedQuery = SecurityUtils.limitText(SecurityUtils.sanitizeText(query), maxLength: 100);
if (!SecurityUtils.isValidInput(sanitizedQuery)) {
if (kDebugMode) print('[API] Invalid search query input: $query');
return SearchResults(users: [], tags: [], posts: []);
}
try {
if (kDebugMode) print('[API] Searching for: $sanitizedQuery');
final data = await callGoApi(
'/search',
method: 'GET',
queryParams: {'q': sanitizedQuery},
);
// if (kDebugMode) print('[API] Search raw response: ${jsonEncode(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 SearchResults(users: [], tags: [], posts: []);
}

View file

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

View file

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

View file

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

View file

@ -7,7 +7,7 @@ class UrlLauncherHelper {
// List of known safe domains
static const List<String> _safeDomains = [
'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',
'instagram.com', 'www.instagram.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();
// 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;
}
// Legacy: media.gosojorn.com might need signing depending on setup
if (host == 'media.gosojorn.com') {
// Legacy: media.sojorn.net might need signing depending on setup
if (host == 'media.sojorn.net') {
return true;
}

View file

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

View file

@ -14,7 +14,7 @@ import '../../routes/app_routes.dart';
String _resolveAvatarUrl(String? url) {
if (url == null || url.isEmpty) return '';
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.

View file

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

View file

@ -5,4 +5,4 @@ echo Starting Sojorn on Chrome...
echo.
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.
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
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
4. **Monitoring**: Real-time log monitoring for errors
@ -212,7 +212,7 @@ for _, origin := range allowedOrigins {
**DNS Switch:**
```bash
# Update A record for api.gosojorn.com
# Update A record for api.sojorn.net
# Monitor propagation
# Watch error rates
```
@ -223,7 +223,7 @@ for _, origin := range allowedOrigins {
journalctl -u sojorn-api -f
# Check error rates
curl -s https://api.gosojorn.com/health
curl -s https://api.sojorn.net/health
# Validate data integrity
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
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
3. **Service Restart**: Restart Supabase Edge Functions
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.
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
@ -136,19 +138,19 @@ FIREBASE_CREDENTIALS_FILE=/opt/sojorn/firebase-service-account.json
FIREBASE_WEB_VAPID_KEY=BNxS7_your_vapid_key_here
# Storage
R2_ACCOUNT_ID=your-r2-account-id
R2_ACCESS_KEY_ID=your-access-key
R2_SECRET_ACCESS_KEY=your-secret-key
R2_BUCKET_NAME=sojorn-uploads
R2_PUBLIC_BASE_URL=https://pub-xxxxx.r2.dev
R2_IMG_DOMAIN=img.sojorn.com
R2_VID_DOMAIN=vid.sojorn.com
SENDER_API_TOKEN=eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJhdWQiOiIxIiwianRpIjoiNT>
R2_ACCOUNT_ID=7041ca6e0f40307190dc2e65e2fb5e0f
R2_PUBLIC_BASE_URL=http://api.sojorn.net:8080/uploads
R2_IMG_DOMAIN=img.sojorn.net
R2_VID_DOMAIN=quips.sojorn.net
R2_API_TOKEN=oR7Vk0Realtx0D6SAGMuYA8pXopSoCYKv8t3JEuk
# Email (Optional)
SMTP_HOST=smtp.gmail.com
# Email / SendPulse
SMTP_HOST=smtp-pulse.com
SMTP_PORT=587
SMTP_USER=noreply@sojorn.com
SMTP_PASS=your-app-password
SMTP_USER=patrickbritton3@gmail.com
SMTP_PASS=8s4jQBnAFTCXPNM
# Logging
LOG_LEVEL=info
@ -245,7 +247,7 @@ Create `/etc/nginx/sites-available/sojorn-api`:
```nginx
server {
listen 80;
server_name api.gosojorn.com;
server_name api.sojorn.net;
# Redirect to HTTPS
return 301 https://$server_name$request_uri;
@ -253,11 +255,11 @@ server {
server {
listen 443 ssl http2;
server_name api.gosojorn.com;
server_name api.sojorn.net;
# SSL Configuration
ssl_certificate /etc/letsencrypt/live/api.gosojorn.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/api.gosojorn.com/privkey.pem;
ssl_certificate /etc/letsencrypt/live/api.sojorn.net/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/api.sojorn.net/privkey.pem;
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_prefer_server_ciphers off;
@ -346,7 +348,7 @@ sudo systemctl reload nginx
sudo apt install certbot python3-certbot-nginx
# Obtain certificate
sudo certbot --nginx -d api.gosojorn.com
sudo certbot --nginx -d api.sojorn.net
# Test renewal
sudo certbot renew --dry-run
@ -556,7 +558,7 @@ psql -h localhost -U sojorn_user -d sojorn
sudo systemctl start sojorn-api
# Verify recovery
curl -f https://api.gosojorn.com/health
curl -f https://api.sojorn.net/health
```
#### File Recovery
@ -939,7 +941,7 @@ sudo systemctl reload nginx
#### Problem: SSL handshake failed
```bash
# Test SSL configuration
openssl s_client -connect api.gosojorn.com:443
openssl s_client -connect api.sojorn.net:443
# Check Nginx configuration
sudo nginx -t
@ -1003,7 +1005,7 @@ sudo systemctl reload nginx
sudo systemctl restart postgresql
# Verify recovery
curl -f https://api.gosojorn.com/health
curl -f https://api.sojorn.net/health
```
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
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`
@ -140,7 +140,7 @@ R2_SECRET_KEY=...
| `internal/handlers/user_handler.go` | Profile & social endpoints |
| `internal/repository/post_repository.go` | Post database queries |
| `internal/repository/user_repository.go` | User database queries |
aaa
---
## Server Deployment

View file

@ -50,7 +50,7 @@ token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error)
**Verification**:
```bash
# 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
# 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**:
@ -527,8 +527,8 @@ sudo systemctl reload nginx
#### 2. Fix Nginx SSL Config
```nginx
ssl_certificate /etc/letsencrypt/live/api.gosojorn.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/api.gosojorn.com/privkey.pem;
ssl_certificate /etc/letsencrypt/live/api.sojorn.net/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/api.sojorn.net/privkey.pem;
```
### DNS Propagation Issues
@ -541,11 +541,11 @@ ssl_certificate_key /etc/letsencrypt/live/api.gosojorn.com/privkey.pem;
**Diagnostics**:
```bash
# Check DNS resolution
nslookup api.gosojorn.com
dig api.gosojorn.com
nslookup api.sojorn.net
dig api.sojorn.net
# 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**:
@ -553,11 +553,11 @@ for i in {1..10}; do echo "Attempt $i:"; dig api.gosojorn.com +short; sleep 30;
#### 1. Verify DNS Records
```bash
# Check A record
dig api.gosojorn.com A
dig api.sojorn.net A
# Check with multiple DNS servers
dig @8.8.8.8 api.gosojorn.com
dig @1.1.1.1 api.gosojorn.com
dig @8.8.8.8 api.sojorn.net
dig @1.1.1.1 api.sojorn.net
```
#### 2. Reduce TTL Before Changes
@ -581,7 +581,7 @@ sudo -u postgres psql -c "SELECT count(*) FROM users;"
# Network
sudo netstat -tlnp | grep :8080
curl -I https://api.gosojorn.com/health
curl -I https://api.sojorn.net/health
# Logs
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**:
```bash
curl https://api.gosojorn.com/health
curl https://api.sojorn.net/health
```
### 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)
- 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
---
@ -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.
```bash
certbot --nginx -d gosojorn.com -d www.gosojorn.com
certbot --nginx -d sojorn.net -d www.sojorn.net
```
Follow the prompts:
@ -130,21 +130,21 @@ server {
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_key /etc/letsencrypt/live/gosojorn.com/privkey.pem;
ssl_certificate /etc/letsencrypt/live/sojorn.net/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/sojorn.net/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
return 301 https://gosojorn.com$request_uri;
return 301 https://sojorn.net$request_uri;
}
# Main server block
server {
listen 80;
listen [::]:80;
server_name gosojorn.com;
server_name sojorn.net;
# Redirect HTTP to HTTPS
return 301 https://$server_name$request_uri;
@ -153,11 +153,11 @@ server {
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name gosojorn.com;
server_name sojorn.net;
# SSL Configuration
ssl_certificate /etc/letsencrypt/live/gosojorn.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/gosojorn.com/privkey.pem;
ssl_certificate /etc/letsencrypt/live/sojorn.net/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/sojorn.net/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
@ -327,8 +327,8 @@ chmod -R 755 /var/www/sojorn
## Part 8: Test Your Deployment
1. Visit `https://gosojorn.com` - you should see your app
2. Test deep linking: `https://gosojorn.com/username` should route to a profile
1. Visit `https://sojorn.net` - you should see your app
2. Test deep linking: `https://sojorn.net/username` should route to a profile
3. Check SSL: Look for the padlock icon in the browser
---
@ -508,10 +508,10 @@ certbot renew
You now have:
✅ Nginx web server installed and configured
✅ 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
✅ Gzip compression for better performance
✅ Proper security headers
✅ 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)
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.
4. **Monitor**: Watch logs for 4xx/5xx errors.
```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`).
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.
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
- **Service Health:**
@ -51,4 +51,4 @@ The infrastructure for GoSojorn is **now fully functional and production-ready**
- **Status:** Stress test threshold MET.
## 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:
1. ✅ **Custom Domain Connected**: `media.gosojorn.com` → R2 bucket `sojorn-media`
2. ✅ **Environment Variable Set**: `R2_PUBLIC_URL=https://media.gosojorn.com`
1. ✅ **Custom Domain Connected**: `media.sojorn.net` → R2 bucket `sojorn-media`
2. ✅ **Environment Variable Set**: `R2_PUBLIC_URL=https://media.sojorn.net`
3. ✅ **Edge Function Deployed**: Updated `upload-image` function using custom domain
4. ✅ **DNS Verified**: Domain resolving to Cloudflare CDN
5. ✅ **API Queries Fixed**: All post queries include `image_url` field
@ -28,7 +28,7 @@ In the app:
**Expected behavior**:
- Image uploads successfully
- 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) ✅
@ -43,7 +43,7 @@ ORDER BY created_at DESC
LIMIT 5;
```
Expected format: `https://media.gosojorn.com/[uuid].[ext]`
Expected format: `https://media.sojorn.net/[uuid].[ext]`
## Troubleshooting
@ -65,7 +65,7 @@ Look for:
After uploading an image, copy its URL from the database and test:
```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`
@ -129,7 +129,7 @@ Complete guides available:
| API Queries | ✅ Include `image_url` field |
| Flutter Model | ✅ Post model parses `image_url` |
| Widget Display | ✅ PostItem widget shows images |
| Custom Domain | ✅ `media.gosojorn.com` connected |
| Custom Domain | ✅ `media.sojorn.net` connected |
**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):**
```
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,
status, created_at, edited_at, deleted_at, allow_chain,
chain_parent_id, image_url, chain_parent, metrics, author]
@ -271,9 +271,9 @@ I/flutter: PostMedia: post.imageUrl = null
### After Fix
```
I/flutter: DEBUG Post.fromJson: Found image_url in JSON: https://media.gosojorn.com/88a7cc72-...
I/flutter: PostMedia: post.imageUrl = https://media.gosojorn.com/88a7cc72-...
I/flutter: PostMedia: SHOWING IMAGE for 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.sojorn.net/88a7cc72-...
I/flutter: PostMedia: SHOWING IMAGE for https://media.sojorn.net/88a7cc72-...
I/flutter: PostMedia: Image loading... 8899 / 275401
I/flutter: PostMedia: Image LOADED successfully
```
@ -291,7 +291,7 @@ npx supabase functions deploy feed-sojorn feed-personal --no-verify-jwt
### Image Upload Flow (Already Working)
1. User selects image in `ComposeScreen`
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
5. Saved to `posts.image_url` column

View file

@ -4,9 +4,9 @@
- **R2 Bucket**: `sojorn-media`
- **Account ID**: `7041ca6e0f40307190dc2e65e2fb5e0f`
- **Custom Domain**: `media.gosojorn.com`
- **Custom Domain**: `media.sojorn.net`
- **Upload URL**: `https://7041ca6e0f40307190dc2e65e2fb5e0f.r2.cloudflarestorage.com/sojorn-media`
- **Public URL**: `https://media.gosojorn.com`
- **Public URL**: `https://media.sojorn.net`
## Quick Test
@ -15,11 +15,11 @@
Go to: https://dash.cloudflare.com → R2 → `sojorn-media` bucket → Settings
Under "Custom Domains", you should see:
- ✅ `media.gosojorn.com` with status "Active"
- ✅ `media.sojorn.net` with status "Active"
If not connected:
1. Click "Connect Domain"
2. Enter: `media.gosojorn.com`
2. Enter: `media.sojorn.net`
3. Wait 1-2 minutes for activation
### 2. Test Upload in App
@ -47,14 +47,14 @@ ORDER BY created_at DESC
LIMIT 1;
```
**Expected URL format**: `https://media.gosojorn.com/[uuid].[ext]`
**Expected URL format**: `https://media.sojorn.net/[uuid].[ext]`
### 4. Test URL Directly
Copy the image_url from database and test in browser or curl:
```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`
@ -91,7 +91,7 @@ Required secrets:
| Issue | Cause | Solution |
|-------|-------|----------|
| 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 |
| Image loads slowly | Not cached | Normal for first load, subsequent loads cached |
@ -100,7 +100,7 @@ Required secrets:
**Upload Flow**:
1. User selects image → App processes/filters
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
5. Feed queries posts with image_url
6. PostItem widget displays image