feat: Implement comprehensive reaction display widget, add numerous new screens, services, models, documentation, and configuration files.
This commit is contained in:
parent
72ae644758
commit
f77bd72c57
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -153,3 +153,4 @@ temp_server.env
|
|||
.zshrc
|
||||
.profile
|
||||
|
||||
sojorn_docs/SOJORN_ARCHITECTURE.md
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
api.gosojorn.com {
|
||||
api.sojorn.net {
|
||||
reverse_proxy localhost:8080
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
34
nginx/legacy_redirect.conf
Normal file
34
nginx/legacy_redirect.conf
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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
34
nginx/sojorn_net.conf
Normal 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;
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
14
run_web.ps1
14
run_web.ps1
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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/ {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)}';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)}';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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.')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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: []);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'];
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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!),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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**
|
||||
|
|
|
|||
95
sojorn_docs/MIGRATION_STEP_BY_STEP.txt
Normal file
95
sojorn_docs/MIGRATION_STEP_BY_STEP.txt
Normal 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.
|
||||
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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! 🎉
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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!** 🚀
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue