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
|
.zshrc
|
||||||
.profile
|
.profile
|
||||||
|
|
||||||
|
sojorn_docs/SOJORN_ARCHITECTURE.md
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
api.gosojorn.com {
|
api.sojorn.net {
|
||||||
reverse_proxy localhost:8080
|
reverse_proxy localhost:8080
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { AwsClient } from 'https://esm.sh/aws4fetch@1.0.17'
|
import { AwsClient } from 'https://esm.sh/aws4fetch@1.0.17'
|
||||||
|
|
||||||
const CUSTOM_MEDIA_DOMAIN = (Deno.env.get("CUSTOM_MEDIA_DOMAIN") ?? "https://img.gosojorn.com").trim();
|
const CUSTOM_MEDIA_DOMAIN = (Deno.env.get("CUSTOM_MEDIA_DOMAIN") ?? "https://img.sojorn.net").trim();
|
||||||
const CUSTOM_VIDEO_DOMAIN = (Deno.env.get("CUSTOM_VIDEO_DOMAIN") ?? "https://quips.gosojorn.com").trim();
|
const CUSTOM_VIDEO_DOMAIN = (Deno.env.get("CUSTOM_VIDEO_DOMAIN") ?? "https://quips.sojorn.net").trim();
|
||||||
|
|
||||||
const DEFAULT_BUCKET_NAME = "sojorn-media";
|
const DEFAULT_BUCKET_NAME = "sojorn-media";
|
||||||
const RESOLVED_BUCKET = (Deno.env.get("R2_BUCKET_NAME") ?? DEFAULT_BUCKET_NAME).trim();
|
const RESOLVED_BUCKET = (Deno.env.get("R2_BUCKET_NAME") ?? DEFAULT_BUCKET_NAME).trim();
|
||||||
|
|
@ -36,8 +36,8 @@ export function transformLegacyMediaUrl(input: string): string | null {
|
||||||
try {
|
try {
|
||||||
const url = new URL(trimmed);
|
const url = new URL(trimmed);
|
||||||
|
|
||||||
// Handle legacy media.gosojorn.com URLs
|
// Handle legacy media.sojorn.net URLs
|
||||||
if (url.hostname === 'media.gosojorn.com') {
|
if (url.hostname === 'media.sojorn.net') {
|
||||||
const key = decodeURIComponent(url.pathname);
|
const key = decodeURIComponent(url.pathname);
|
||||||
return key;
|
return key;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { serve } from 'https://deno.land/std@0.177.0/http/server.ts';
|
||||||
import { S3Client, DeleteObjectCommand } from 'https://esm.sh/@aws-sdk/client-s3@3.470.0';
|
import { S3Client, DeleteObjectCommand } from 'https://esm.sh/@aws-sdk/client-s3@3.470.0';
|
||||||
import { createServiceClient } from '../_shared/supabase-client.ts';
|
import { createServiceClient } from '../_shared/supabase-client.ts';
|
||||||
|
|
||||||
const ALLOWED_ORIGIN = Deno.env.get('ALLOWED_ORIGIN') || 'https://gosojorn.com';
|
const ALLOWED_ORIGIN = Deno.env.get('ALLOWED_ORIGIN') || 'https://sojorn.net';
|
||||||
|
|
||||||
const corsHeaders = {
|
const corsHeaders = {
|
||||||
'Access-Control-Allow-Origin': ALLOWED_ORIGIN,
|
'Access-Control-Allow-Origin': ALLOWED_ORIGIN,
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@
|
||||||
import { serve } from 'https://deno.land/std@0.177.0/http/server.ts';
|
import { serve } from 'https://deno.land/std@0.177.0/http/server.ts';
|
||||||
import { createSupabaseClient } from '../_shared/supabase-client.ts';
|
import { createSupabaseClient } from '../_shared/supabase-client.ts';
|
||||||
|
|
||||||
const ALLOWED_ORIGIN = Deno.env.get('ALLOWED_ORIGIN') || 'https://gosojorn.com';
|
const ALLOWED_ORIGIN = Deno.env.get('ALLOWED_ORIGIN') || 'https://sojorn.net';
|
||||||
|
|
||||||
serve(async (req) => {
|
serve(async (req) => {
|
||||||
if (req.method === 'OPTIONS') {
|
if (req.method === 'OPTIONS') {
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@
|
||||||
import { serve } from 'https://deno.land/std@0.177.0/http/server.ts';
|
import { serve } from 'https://deno.land/std@0.177.0/http/server.ts';
|
||||||
import { createSupabaseClient } from '../_shared/supabase-client.ts';
|
import { createSupabaseClient } from '../_shared/supabase-client.ts';
|
||||||
|
|
||||||
const ALLOWED_ORIGIN = Deno.env.get('ALLOWED_ORIGIN') || 'https://gosojorn.com';
|
const ALLOWED_ORIGIN = Deno.env.get('ALLOWED_ORIGIN') || 'https://sojorn.net';
|
||||||
|
|
||||||
serve(async (req) => {
|
serve(async (req) => {
|
||||||
if (req.method === 'OPTIONS') {
|
if (req.method === 'OPTIONS') {
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts';
|
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts';
|
||||||
import { createSupabaseClient, createServiceClient } from '../_shared/supabase-client.ts';
|
import { createSupabaseClient, createServiceClient } from '../_shared/supabase-client.ts';
|
||||||
|
|
||||||
const ALLOWED_ORIGIN = Deno.env.get('ALLOWED_ORIGIN') || 'https://gosojorn.com';
|
const ALLOWED_ORIGIN = Deno.env.get('ALLOWED_ORIGIN') || 'https://sojorn.net';
|
||||||
|
|
||||||
const corsHeaders = {
|
const corsHeaders = {
|
||||||
'Access-Control-Allow-Origin': ALLOWED_ORIGIN,
|
'Access-Control-Allow-Origin': ALLOWED_ORIGIN,
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,7 @@ serve(async (req) => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Transform legacy media.gosojorn.com URLs to their object key
|
// Transform legacy media.sojorn.net URLs to their object key
|
||||||
const transformedTarget = transformLegacyMediaUrl(target) ?? target;
|
const transformedTarget = transformLegacyMediaUrl(target) ?? target;
|
||||||
|
|
||||||
const signedUrl = await trySignR2Url(transformedTarget, expiresIn);
|
const signedUrl = await trySignR2Url(transformedTarget, expiresIn);
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ serve(async (req) => {
|
||||||
if (req.method === 'OPTIONS') {
|
if (req.method === 'OPTIONS') {
|
||||||
return new Response(null, {
|
return new Response(null, {
|
||||||
headers: {
|
headers: {
|
||||||
'Access-Control-Allow-Origin': Deno.env.get('ALLOWED_ORIGIN') || 'https://gosojorn.com',
|
'Access-Control-Allow-Origin': Deno.env.get('ALLOWED_ORIGIN') || 'https://sojorn.net',
|
||||||
'Access-Control-Allow-Methods': 'POST',
|
'Access-Control-Allow-Methods': 'POST',
|
||||||
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
|
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
|
||||||
|
|
||||||
const OPENAI_MODERATION_URL = 'https://api.openai.com/v1/moderations'
|
const OPENAI_MODERATION_URL = 'https://api.openai.com/v1/moderations'
|
||||||
|
|
||||||
const ALLOWED_ORIGIN = Deno.env.get('ALLOWED_ORIGIN') || 'https://gosojorn.com';
|
const ALLOWED_ORIGIN = Deno.env.get('ALLOWED_ORIGIN') || 'https://sojorn.net';
|
||||||
|
|
||||||
const corsHeaders = {
|
const corsHeaders = {
|
||||||
'Access-Control-Allow-Origin': ALLOWED_ORIGIN,
|
'Access-Control-Allow-Origin': ALLOWED_ORIGIN,
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-6">
|
<div class="flex items-center gap-6">
|
||||||
<a href="/email" class="text-sm font-semibold hover:text-egyptianBlue transition">Newsletter</a>
|
<a href="/email" class="text-sm font-semibold hover:text-egyptianBlue transition">Newsletter</a>
|
||||||
<a href="mailto:contact@gosojorn.com"
|
<a href="mailto:contact@sojorn.net"
|
||||||
class="text-sm font-semibold hover:text-egyptianBlue transition">Contact</a>
|
class="text-sm font-semibold hover:text-egyptianBlue transition">Contact</a>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
@ -119,8 +119,8 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="mt-6 text-sm text-gray-500">
|
<p class="mt-6 text-sm text-gray-500">
|
||||||
Join the waitlist by emailing <a href="mailto:waitlist@gosojorn.com"
|
Join the waitlist by emailing <a href="mailto:waitlist@sojorn.net"
|
||||||
class="text-egyptianBlue underline">waitlist@gosojorn.com</a>
|
class="text-egyptianBlue underline">waitlist@sojorn.net</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
|
||||||
|
|
@ -73,8 +73,8 @@
|
||||||
you leave, you leave. We do not retain hidden profiles.</p>
|
you leave, you leave. We do not retain hidden profiles.</p>
|
||||||
|
|
||||||
<h2 class="text-xl font-bold mt-6 mb-4">5. Contact</h2>
|
<h2 class="text-xl font-bold mt-6 mb-4">5. Contact</h2>
|
||||||
<p class="mb-4">For privacy concerns: <a href="mailto:privacy@gosojorn.com"
|
<p class="mb-4">For privacy concerns: <a href="mailto:privacy@sojorn.net"
|
||||||
class="text-egyptianBlue underline">privacy@gosojorn.com</a>.</p>
|
class="text-egyptianBlue underline">privacy@sojorn.net</a>.</p>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,28 +1,28 @@
|
||||||
-- Fix sojorn-media URLs (for images) to img.gosojorn.com
|
-- Fix sojorn-media URLs (for images) to img.sojorn.net
|
||||||
UPDATE profiles
|
UPDATE profiles
|
||||||
SET avatar_url = REGEXP_REPLACE(avatar_url, 'https://[a-zA-Z0-9]+\.r2\.cloudflarestorage\.com/sojorn-media/', 'https://img.gosojorn.com/', 'g')
|
SET avatar_url = REGEXP_REPLACE(avatar_url, 'https://[a-zA-Z0-9]+\.r2\.cloudflarestorage\.com/sojorn-media/', 'https://img.sojorn.net/', 'g')
|
||||||
WHERE avatar_url LIKE '%r2.cloudflarestorage.com/sojorn-media%';
|
WHERE avatar_url LIKE '%r2.cloudflarestorage.com/sojorn-media%';
|
||||||
|
|
||||||
UPDATE profiles
|
UPDATE profiles
|
||||||
SET cover_url = REGEXP_REPLACE(cover_url, 'https://[a-zA-Z0-9]+\.r2\.cloudflarestorage\.com/sojorn-media/', 'https://img.gosojorn.com/', 'g')
|
SET cover_url = REGEXP_REPLACE(cover_url, 'https://[a-zA-Z0-9]+\.r2\.cloudflarestorage\.com/sojorn-media/', 'https://img.sojorn.net/', 'g')
|
||||||
WHERE cover_url LIKE '%r2.cloudflarestorage.com/sojorn-media%';
|
WHERE cover_url LIKE '%r2.cloudflarestorage.com/sojorn-media%';
|
||||||
|
|
||||||
UPDATE posts
|
UPDATE posts
|
||||||
SET image_url = REGEXP_REPLACE(image_url, 'https://[a-zA-Z0-9]+\.r2\.cloudflarestorage\.com/sojorn-media/', 'https://img.gosojorn.com/', 'g')
|
SET image_url = REGEXP_REPLACE(image_url, 'https://[a-zA-Z0-9]+\.r2\.cloudflarestorage\.com/sojorn-media/', 'https://img.sojorn.net/', 'g')
|
||||||
WHERE image_url LIKE '%r2.cloudflarestorage.com/sojorn-media%';
|
WHERE image_url LIKE '%r2.cloudflarestorage.com/sojorn-media%';
|
||||||
|
|
||||||
UPDATE posts
|
UPDATE posts
|
||||||
SET thumbnail_url = REGEXP_REPLACE(thumbnail_url, 'https://[a-zA-Z0-9]+\.r2\.cloudflarestorage\.com/sojorn-media/', 'https://img.gosojorn.com/', 'g')
|
SET thumbnail_url = REGEXP_REPLACE(thumbnail_url, 'https://[a-zA-Z0-9]+\.r2\.cloudflarestorage\.com/sojorn-media/', 'https://img.sojorn.net/', 'g')
|
||||||
WHERE thumbnail_url LIKE '%r2.cloudflarestorage.com/sojorn-media%';
|
WHERE thumbnail_url LIKE '%r2.cloudflarestorage.com/sojorn-media%';
|
||||||
|
|
||||||
-- Fix sojorn-videos URLs (for quips) to quips.gosojorn.com
|
-- Fix sojorn-videos URLs (for quips) to quips.sojorn.net
|
||||||
UPDATE posts
|
UPDATE posts
|
||||||
SET video_url = REGEXP_REPLACE(video_url, 'https://[a-zA-Z0-9]+\.r2\.cloudflarestorage\.com/sojorn-videos/', 'https://quips.gosojorn.com/', 'g')
|
SET video_url = REGEXP_REPLACE(video_url, 'https://[a-zA-Z0-9]+\.r2\.cloudflarestorage\.com/sojorn-videos/', 'https://quips.sojorn.net/', 'g')
|
||||||
WHERE video_url LIKE '%r2.cloudflarestorage.com/sojorn-videos%';
|
WHERE video_url LIKE '%r2.cloudflarestorage.com/sojorn-videos%';
|
||||||
|
|
||||||
-- Fix the one edge case where image_url contains a video URL
|
-- Fix the one edge case where image_url contains a video URL
|
||||||
UPDATE posts
|
UPDATE posts
|
||||||
SET image_url = REGEXP_REPLACE(image_url, 'https://[a-zA-Z0-9]+\.r2\.cloudflarestorage\.com/sojorn-videos/', 'https://quips.gosojorn.com/', 'g')
|
SET image_url = REGEXP_REPLACE(image_url, 'https://[a-zA-Z0-9]+\.r2\.cloudflarestorage\.com/sojorn-videos/', 'https://quips.sojorn.net/', 'g')
|
||||||
WHERE image_url LIKE '%r2.cloudflarestorage.com/sojorn-videos%';
|
WHERE image_url LIKE '%r2.cloudflarestorage.com/sojorn-videos%';
|
||||||
|
|
||||||
-- Verify after
|
-- Verify after
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
|
|
||||||
-- 1. Fix image URLs that are just filenames (no domain)
|
-- 1. Fix image URLs that are just filenames (no domain)
|
||||||
UPDATE posts
|
UPDATE posts
|
||||||
SET image_url = 'https://img.gosojorn.com/' || image_url
|
SET image_url = 'https://img.sojorn.net/' || image_url
|
||||||
WHERE image_url IS NOT NULL
|
WHERE image_url IS NOT NULL
|
||||||
AND image_url != ''
|
AND image_url != ''
|
||||||
AND image_url NOT LIKE 'http%'
|
AND image_url NOT LIKE 'http%'
|
||||||
|
|
@ -11,7 +11,7 @@ WHERE image_url IS NOT NULL
|
||||||
|
|
||||||
-- 2. Fix video URLs that are just filenames (no domain)
|
-- 2. Fix video URLs that are just filenames (no domain)
|
||||||
UPDATE posts
|
UPDATE posts
|
||||||
SET video_url = 'https://quips.gosojorn.com/' || video_url
|
SET video_url = 'https://quips.sojorn.net/' || video_url
|
||||||
WHERE video_url IS NOT NULL
|
WHERE video_url IS NOT NULL
|
||||||
AND video_url != ''
|
AND video_url != ''
|
||||||
AND video_url NOT LIKE 'http%'
|
AND video_url NOT LIKE 'http%'
|
||||||
|
|
@ -19,7 +19,7 @@ WHERE video_url IS NOT NULL
|
||||||
|
|
||||||
-- 3. Fix thumbnail URLs that are just filenames
|
-- 3. Fix thumbnail URLs that are just filenames
|
||||||
UPDATE posts
|
UPDATE posts
|
||||||
SET thumbnail_url = 'https://img.gosojorn.com/' || thumbnail_url
|
SET thumbnail_url = 'https://img.sojorn.net/' || thumbnail_url
|
||||||
WHERE thumbnail_url IS NOT NULL
|
WHERE thumbnail_url IS NOT NULL
|
||||||
AND thumbnail_url != ''
|
AND thumbnail_url != ''
|
||||||
AND thumbnail_url NOT LIKE 'http%'
|
AND thumbnail_url NOT LIKE 'http%'
|
||||||
|
|
@ -27,7 +27,7 @@ WHERE thumbnail_url IS NOT NULL
|
||||||
|
|
||||||
-- 4. Fix profile avatars that are just filenames
|
-- 4. Fix profile avatars that are just filenames
|
||||||
UPDATE profiles
|
UPDATE profiles
|
||||||
SET avatar_url = 'https://img.gosojorn.com/' || avatar_url
|
SET avatar_url = 'https://img.sojorn.net/' || avatar_url
|
||||||
WHERE avatar_url IS NOT NULL
|
WHERE avatar_url IS NOT NULL
|
||||||
AND avatar_url != ''
|
AND avatar_url != ''
|
||||||
AND avatar_url NOT LIKE 'http%'
|
AND avatar_url NOT LIKE 'http%'
|
||||||
|
|
@ -35,7 +35,7 @@ WHERE avatar_url IS NOT NULL
|
||||||
|
|
||||||
-- 5. Fix profile covers that are just filenames
|
-- 5. Fix profile covers that are just filenames
|
||||||
UPDATE profiles
|
UPDATE profiles
|
||||||
SET cover_url = 'https://img.gosojorn.com/' || cover_url
|
SET cover_url = 'https://img.sojorn.net/' || cover_url
|
||||||
WHERE cover_url IS NOT NULL
|
WHERE cover_url IS NOT NULL
|
||||||
AND cover_url != ''
|
AND cover_url != ''
|
||||||
AND cover_url NOT LIKE 'http%'
|
AND cover_url NOT LIKE 'http%'
|
||||||
|
|
@ -51,12 +51,12 @@ SELECT 'Profiles with covers' as check_type, count(*) as count FROM profiles WHE
|
||||||
-- Show any remaining non-standard URLs
|
-- Show any remaining non-standard URLs
|
||||||
SELECT 'Non-standard image URLs' as type, image_url FROM posts
|
SELECT 'Non-standard image URLs' as type, image_url FROM posts
|
||||||
WHERE image_url IS NOT NULL
|
WHERE image_url IS NOT NULL
|
||||||
AND image_url NOT LIKE 'https://img.gosojorn.com/%'
|
AND image_url NOT LIKE 'https://img.sojorn.net/%'
|
||||||
AND image_url NOT LIKE 'https://quips.gosojorn.com/%'
|
AND image_url NOT LIKE 'https://quips.sojorn.net/%'
|
||||||
LIMIT 5;
|
LIMIT 5;
|
||||||
|
|
||||||
SELECT 'Non-standard video URLs' as type, video_url FROM posts
|
SELECT 'Non-standard video URLs' as type, video_url FROM posts
|
||||||
WHERE video_url IS NOT NULL
|
WHERE video_url IS NOT NULL
|
||||||
AND video_url NOT LIKE 'https://img.gosojorn.com/%'
|
AND video_url NOT LIKE 'https://img.sojorn.net/%'
|
||||||
AND video_url NOT LIKE 'https://quips.gosojorn.com/%'
|
AND video_url NOT LIKE 'https://quips.sojorn.net/%'
|
||||||
LIMIT 5;
|
LIMIT 5;
|
||||||
|
|
|
||||||
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 {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
server_name api.gosojorn.com;
|
server_name api.sojorn.net;
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://localhost:8080;
|
proxy_pass http://localhost:8080;
|
||||||
|
|
@ -12,7 +12,7 @@ server {
|
||||||
|
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
server_name gosojorn.com;
|
server_name sojorn.net;
|
||||||
root /var/www/sojorn;
|
root /var/www/sojorn;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
server {
|
server {
|
||||||
server_name api.gosojorn.com;
|
server_name api.sojorn.net;
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://localhost:8080;
|
proxy_pass http://localhost:8080;
|
||||||
|
|
@ -14,14 +14,14 @@ server {
|
||||||
}
|
}
|
||||||
|
|
||||||
listen 443 ssl; # managed by Certbot
|
listen 443 ssl; # managed by Certbot
|
||||||
ssl_certificate /etc/letsencrypt/live/api.gosojorn.com/fullchain.pem; # managed by Certbot
|
ssl_certificate /etc/letsencrypt/live/api.sojorn.net/fullchain.pem; # managed by Certbot
|
||||||
ssl_certificate_key /etc/letsencrypt/live/api.gosojorn.com/privkey.pem; # managed by Certbot
|
ssl_certificate_key /etc/letsencrypt/live/api.sojorn.net/privkey.pem; # managed by Certbot
|
||||||
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
|
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
|
||||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
|
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
|
||||||
}
|
}
|
||||||
|
|
||||||
server {
|
server {
|
||||||
server_name gosojorn.com;
|
server_name sojorn.net;
|
||||||
root /var/www/sojorn;
|
root /var/www/sojorn;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
|
|
@ -30,21 +30,21 @@ server {
|
||||||
}
|
}
|
||||||
|
|
||||||
listen 443 ssl; # managed by Certbot
|
listen 443 ssl; # managed by Certbot
|
||||||
ssl_certificate /etc/letsencrypt/live/gosojorn.com/fullchain.pem; # managed by Certbot
|
ssl_certificate /etc/letsencrypt/live/sojorn.net/fullchain.pem; # managed by Certbot
|
||||||
ssl_certificate_key /etc/letsencrypt/live/gosojorn.com/privkey.pem; # managed by Certbot
|
ssl_certificate_key /etc/letsencrypt/live/sojorn.net/privkey.pem; # managed by Certbot
|
||||||
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
|
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
|
||||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
|
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
|
||||||
}
|
}
|
||||||
|
|
||||||
server {
|
server {
|
||||||
if ($host = api.gosojorn.com) {
|
if ($host = api.sojorn.net) {
|
||||||
return 301 https://$host$request_uri;
|
return 301 https://$host$request_uri;
|
||||||
}
|
}
|
||||||
if ($host = gosojorn.com) {
|
if ($host = sojorn.net) {
|
||||||
return 301 https://$host$request_uri;
|
return 301 https://$host$request_uri;
|
||||||
}
|
}
|
||||||
|
|
||||||
listen 80;
|
listen 80;
|
||||||
server_name api.gosojorn.com gosojorn.com;
|
server_name api.sojorn.net sojorn.net;
|
||||||
return 404;
|
return 404;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
34
nginx/sojorn_net.conf
Normal file
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 {
|
||||||
server_name gosojorn.com www.gosojorn.com;
|
server_name sojorn.net www.sojorn.net;
|
||||||
|
|
||||||
root /var/www/sojorn;
|
root /var/www/sojorn;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
@ -28,18 +28,18 @@ server {
|
||||||
}
|
}
|
||||||
|
|
||||||
listen 443 ssl; # managed by Certbot
|
listen 443 ssl; # managed by Certbot
|
||||||
ssl_certificate /etc/letsencrypt/live/api.gosojorn.com/fullchain.pem;
|
ssl_certificate /etc/letsencrypt/live/api.sojorn.net/fullchain.pem;
|
||||||
ssl_certificate_key /etc/letsencrypt/live/api.gosojorn.com/privkey.pem;
|
ssl_certificate_key /etc/letsencrypt/live/api.sojorn.net/privkey.pem;
|
||||||
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
|
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
|
||||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
|
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
|
||||||
}
|
}
|
||||||
|
|
||||||
server {
|
server {
|
||||||
if ($host = gosojorn.com) {
|
if ($host = sojorn.net) {
|
||||||
return 301 https://$host$request_uri;
|
return 301 https://$host$request_uri;
|
||||||
} # managed by Certbot
|
} # managed by Certbot
|
||||||
|
|
||||||
listen 80;
|
listen 80;
|
||||||
server_name gosojorn.com www.gosojorn.com;
|
server_name sojorn.net www.sojorn.net;
|
||||||
return 404; # managed by Certbot
|
return 404; # managed by Certbot
|
||||||
}
|
}
|
||||||
|
|
|
||||||
14
run_web.ps1
14
run_web.ps1
|
|
@ -10,7 +10,7 @@ function Parse-Env($path) {
|
||||||
if (-not (Test-Path $path)) {
|
if (-not (Test-Path $path)) {
|
||||||
Write-Host "No .env file found at ${path}. Using defaults." -ForegroundColor Yellow
|
Write-Host "No .env file found at ${path}. Using defaults." -ForegroundColor Yellow
|
||||||
# Set default API_BASE_URL since no .env exists
|
# Set default API_BASE_URL since no .env exists
|
||||||
$vals['API_BASE_URL'] = 'https://api.gosojorn.com/api/v1'
|
$vals['API_BASE_URL'] = 'https://api.sojorn.net/api/v1'
|
||||||
return $vals
|
return $vals
|
||||||
}
|
}
|
||||||
Get-Content $path | ForEach-Object {
|
Get-Content $path | ForEach-Object {
|
||||||
|
|
@ -41,16 +41,18 @@ foreach ($k in $keysOfInterest) {
|
||||||
|
|
||||||
# Ensure API_BASE_URL is set
|
# Ensure API_BASE_URL is set
|
||||||
if (-not $values.ContainsKey('API_BASE_URL') -or [string]::IsNullOrWhiteSpace($values['API_BASE_URL'])) {
|
if (-not $values.ContainsKey('API_BASE_URL') -or [string]::IsNullOrWhiteSpace($values['API_BASE_URL'])) {
|
||||||
$currentApi = 'https://api.gosojorn.com/api/v1'
|
$currentApi = 'https://api.sojorn.net/api/v1'
|
||||||
$defineArgs += "--dart-define=API_BASE_URL=$currentApi"
|
$defineArgs += "--dart-define=API_BASE_URL=$currentApi"
|
||||||
} else {
|
}
|
||||||
|
else {
|
||||||
$currentApi = $values['API_BASE_URL']
|
$currentApi = $values['API_BASE_URL']
|
||||||
# Always ensure we're using the HTTPS endpoint
|
# Always ensure we're using the HTTPS endpoint
|
||||||
if ($currentApi.StartsWith('http://api.gosojorn.com:8080')) {
|
if ($currentApi.StartsWith('http://api.sojorn.net:8080')) {
|
||||||
$currentApi = $currentApi.Replace('http://api.gosojorn.com:8080', 'https://api.gosojorn.com')
|
$currentApi = $currentApi.Replace('http://api.sojorn.net:8080', 'https://api.sojorn.net')
|
||||||
$defineArgs = $defineArgs | Where-Object { -not ($_ -like '--dart-define=API_BASE_URL=*') }
|
$defineArgs = $defineArgs | Where-Object { -not ($_ -like '--dart-define=API_BASE_URL=*') }
|
||||||
$defineArgs += "--dart-define=API_BASE_URL=$currentApi"
|
$defineArgs += "--dart-define=API_BASE_URL=$currentApi"
|
||||||
} elseif ($currentApi.StartsWith('http://localhost:')) {
|
}
|
||||||
|
elseif ($currentApi.StartsWith('http://localhost:')) {
|
||||||
# For local development, keep localhost but warn
|
# For local development, keep localhost but warn
|
||||||
Write-Host "Using local API: $currentApi" -ForegroundColor Yellow
|
Write-Host "Using local API: $currentApi" -ForegroundColor Yellow
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ function Parse-Env($path) {
|
||||||
if (-not (Test-Path $path)) {
|
if (-not (Test-Path $path)) {
|
||||||
Write-Host "No .env file found at ${path}. Using defaults." -ForegroundColor Yellow
|
Write-Host "No .env file found at ${path}. Using defaults." -ForegroundColor Yellow
|
||||||
# Set default API_BASE_URL since no .env exists
|
# Set default API_BASE_URL since no .env exists
|
||||||
$vals['API_BASE_URL'] = 'https://api.gosojorn.com/api/v1'
|
$vals['API_BASE_URL'] = 'https://api.sojorn.net/api/v1'
|
||||||
return $vals
|
return $vals
|
||||||
}
|
}
|
||||||
Get-Content $path | ForEach-Object {
|
Get-Content $path | ForEach-Object {
|
||||||
|
|
@ -41,16 +41,18 @@ foreach ($k in $keysOfInterest) {
|
||||||
|
|
||||||
# Ensure API_BASE_URL is set
|
# Ensure API_BASE_URL is set
|
||||||
if (-not $values.ContainsKey('API_BASE_URL') -or [string]::IsNullOrWhiteSpace($values['API_BASE_URL'])) {
|
if (-not $values.ContainsKey('API_BASE_URL') -or [string]::IsNullOrWhiteSpace($values['API_BASE_URL'])) {
|
||||||
$currentApi = 'https://api.gosojorn.com/api/v1'
|
$currentApi = 'https://api.sojorn.net/api/v1'
|
||||||
$defineArgs += "--dart-define=API_BASE_URL=$currentApi"
|
$defineArgs += "--dart-define=API_BASE_URL=$currentApi"
|
||||||
} else {
|
}
|
||||||
|
else {
|
||||||
$currentApi = $values['API_BASE_URL']
|
$currentApi = $values['API_BASE_URL']
|
||||||
# Always ensure we're using the HTTPS endpoint
|
# Always ensure we're using the HTTPS endpoint
|
||||||
if ($currentApi.StartsWith('http://api.gosojorn.com:8080')) {
|
if ($currentApi.StartsWith('http://api.sojorn.net:8080')) {
|
||||||
$currentApi = $currentApi.Replace('http://api.gosojorn.com:8080', 'https://api.gosojorn.com')
|
$currentApi = $currentApi.Replace('http://api.sojorn.net:8080', 'https://api.sojorn.net')
|
||||||
$defineArgs = $defineArgs | Where-Object { -not ($_ -like '--dart-define=API_BASE_URL=*') }
|
$defineArgs = $defineArgs | Where-Object { -not ($_ -like '--dart-define=API_BASE_URL=*') }
|
||||||
$defineArgs += "--dart-define=API_BASE_URL=$currentApi"
|
$defineArgs += "--dart-define=API_BASE_URL=$currentApi"
|
||||||
} elseif ($currentApi.StartsWith('http://localhost:')) {
|
}
|
||||||
|
elseif ($currentApi.StartsWith('http://localhost:')) {
|
||||||
# For local development, keep localhost but warn
|
# For local development, keep localhost but warn
|
||||||
Write-Host "Using local API: $currentApi" -ForegroundColor Yellow
|
Write-Host "Using local API: $currentApi" -ForegroundColor Yellow
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ function Parse-Env($path) {
|
||||||
if (-not (Test-Path $path)) {
|
if (-not (Test-Path $path)) {
|
||||||
Write-Host "No .env file found at ${path}. Using defaults." -ForegroundColor Yellow
|
Write-Host "No .env file found at ${path}. Using defaults." -ForegroundColor Yellow
|
||||||
# Set default API_BASE_URL since no .env exists
|
# Set default API_BASE_URL since no .env exists
|
||||||
$vals['API_BASE_URL'] = 'https://api.gosojorn.com/api/v1'
|
$vals['API_BASE_URL'] = 'https://api.sojorn.net/api/v1'
|
||||||
return $vals
|
return $vals
|
||||||
}
|
}
|
||||||
Get-Content $path | ForEach-Object {
|
Get-Content $path | ForEach-Object {
|
||||||
|
|
@ -40,9 +40,10 @@ foreach ($k in $keysOfInterest) {
|
||||||
|
|
||||||
# Ensure API_BASE_URL is set
|
# Ensure API_BASE_URL is set
|
||||||
if (-not $values.ContainsKey('API_BASE_URL') -or [string]::IsNullOrWhiteSpace($values['API_BASE_URL'])) {
|
if (-not $values.ContainsKey('API_BASE_URL') -or [string]::IsNullOrWhiteSpace($values['API_BASE_URL'])) {
|
||||||
$currentApi = 'https://api.gosojorn.com/api/v1'
|
$currentApi = 'https://api.sojorn.net/api/v1'
|
||||||
$defineArgs += "--dart-define=API_BASE_URL=$currentApi"
|
$defineArgs += "--dart-define=API_BASE_URL=$currentApi"
|
||||||
} else {
|
}
|
||||||
|
else {
|
||||||
$currentApi = $values['API_BASE_URL']
|
$currentApi = $values['API_BASE_URL']
|
||||||
if ($currentApi.StartsWith('http://localhost:')) {
|
if ($currentApi.StartsWith('http://localhost:')) {
|
||||||
# For local development, keep localhost but warn
|
# For local development, keep localhost but warn
|
||||||
|
|
@ -71,7 +72,8 @@ try {
|
||||||
|
|
||||||
if ($Release) {
|
if ($Release) {
|
||||||
$cmdArgs += '--release'
|
$cmdArgs += '--release'
|
||||||
} else {
|
}
|
||||||
|
else {
|
||||||
$cmdArgs += '--debug'
|
$cmdArgs += '--debug'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -99,10 +101,12 @@ try {
|
||||||
Write-Host "Starting Sojorn Windows app..." -ForegroundColor Yellow
|
Write-Host "Starting Sojorn Windows app..." -ForegroundColor Yellow
|
||||||
Start-Process -FilePath $exePath
|
Start-Process -FilePath $exePath
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
|
else {
|
||||||
Write-Host "Build completed but executable not found at: $exePath" -ForegroundColor Red
|
Write-Host "Build completed but executable not found at: $exePath" -ForegroundColor Red
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
|
else {
|
||||||
Write-Host "Build failed!" -ForegroundColor Red
|
Write-Host "Build failed!" -ForegroundColor Red
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
server_name api.gosojorn.com;
|
server_name api.sojorn.net;
|
||||||
|
|
||||||
# Allow Certbot to validate (it uses .well-known/acme-challenge)
|
# Allow Certbot to validate (it uses .well-known/acme-challenge)
|
||||||
location /.well-known/acme-challenge/ {
|
location /.well-known/acme-challenge/ {
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,15 @@
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
server_name api.gosojorn.com;
|
server_name api.sojorn.net;
|
||||||
return 301 https://api.gosojorn.com$request_uri;
|
return 301 https://api.sojorn.net$request_uri;
|
||||||
}
|
}
|
||||||
|
|
||||||
server {
|
server {
|
||||||
listen 443 ssl http2;
|
listen 443 ssl http2;
|
||||||
server_name api.gosojorn.com;
|
server_name api.sojorn.net;
|
||||||
|
|
||||||
ssl_certificate /etc/letsencrypt/live/api.gosojorn.com/fullchain.pem;
|
ssl_certificate /etc/letsencrypt/live/api.sojorn.net/fullchain.pem;
|
||||||
ssl_certificate_key /etc/letsencrypt/live/api.gosojorn.com/privkey.pem;
|
ssl_certificate_key /etc/letsencrypt/live/api.sojorn.net/privkey.pem;
|
||||||
include /etc/letsencrypt/options-ssl-nginx.conf;
|
include /etc/letsencrypt/options-ssl-nginx.conf;
|
||||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,15 @@
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
server_name api.gosojorn.com;
|
server_name api.sojorn.net;
|
||||||
return 301 https://api.gosojorn.com$request_uri;
|
return 301 https://api.sojorn.net$request_uri;
|
||||||
}
|
}
|
||||||
|
|
||||||
server {
|
server {
|
||||||
listen 443 ssl http2;
|
listen 443 ssl http2;
|
||||||
server_name api.gosojorn.com;
|
server_name api.sojorn.net;
|
||||||
|
|
||||||
ssl_certificate /etc/letsencrypt/live/gosojorn.com/fullchain.pem;
|
ssl_certificate /etc/letsencrypt/live/sojorn.net/fullchain.pem;
|
||||||
ssl_certificate_key /etc/letsencrypt/live/gosojorn.com/privkey.pem;
|
ssl_certificate_key /etc/letsencrypt/live/sojorn.net/privkey.pem;
|
||||||
include /etc/letsencrypt/options-ssl-nginx.conf;
|
include /etc/letsencrypt/options-ssl-nginx.conf;
|
||||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@
|
||||||
<action android:name="android.intent.action.VIEW" />
|
<action android:name="android.intent.action.VIEW" />
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
<category android:name="android.intent.category.BROWSABLE" />
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
<data android:scheme="sojorn" android:host="beacon" />
|
<data android:scheme="https" android:host="sojorn.net" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<!-- Deep link for verification: sojorn://verified -->
|
<!-- Deep link for verification: sojorn://verified -->
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
<resources>
|
<resources>
|
||||||
<string name="default_notification_channel_id">chat_messages</string>
|
<string name="default_notification_channel_id">sojorn_notifications</string>
|
||||||
<string name="default_notification_channel_name">Chat messages</string>
|
<string name="default_notification_channel_name">Sojorn Notifications</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
||||||
|
|
@ -5,19 +5,24 @@ class ApiConfig {
|
||||||
static String _computeBaseUrl() {
|
static String _computeBaseUrl() {
|
||||||
final raw = const String.fromEnvironment(
|
final raw = const String.fromEnvironment(
|
||||||
'API_BASE_URL',
|
'API_BASE_URL',
|
||||||
defaultValue: 'https://api.gosojorn.com/api/v1',
|
defaultValue: 'https://api.sojorn.net/api/v1',
|
||||||
);
|
);
|
||||||
|
|
||||||
// Auto-upgrade any lingering http://api.gosojorn.com:8080 (or plain http)
|
// Auto-upgrade any lingering http://api.sojorn.net:8080 (or plain http)
|
||||||
// to the public https endpoint behind nginx. This protects old .env files
|
// to the public https endpoint behind nginx. This protects old .env files
|
||||||
// or cached web builds that still point at the closed port 8080.
|
// or cached web builds that still point at the closed port 8080.
|
||||||
if (raw.startsWith('http://api.gosojorn.com:8080')) {
|
if (raw.startsWith('http://api.sojorn.net:8080')) {
|
||||||
return raw.replaceFirst(
|
return raw.replaceFirst(
|
||||||
'http://api.gosojorn.com:8080',
|
'http://api.sojorn.net:8080',
|
||||||
'https://api.gosojorn.com',
|
'https://api.sojorn.net',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Belt-and-suspenders: Force migration even if args/env are stale
|
||||||
|
if (raw.contains('gosojorn.com')) {
|
||||||
|
return raw.replaceAll('gosojorn.com', 'sojorn.net');
|
||||||
|
}
|
||||||
|
|
||||||
if (raw.startsWith('http://')) {
|
if (raw.startsWith('http://')) {
|
||||||
return 'https://${raw.substring('http://'.length)}';
|
return 'https://${raw.substring('http://'.length)}';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,19 @@ import 'profile.dart';
|
||||||
|
|
||||||
/// Types of notifications
|
/// Types of notifications
|
||||||
enum NotificationType {
|
enum NotificationType {
|
||||||
appreciate, // Someone appreciated your post
|
like, // Someone liked your post
|
||||||
chain, // Someone chained your post
|
comment, // Someone commented on your post
|
||||||
follow, // Someone followed you
|
reply, // Someone replied to your post (chained)
|
||||||
follow_request, // Someone requested to follow you
|
mention, // Someone mentioned you
|
||||||
new_follower, // Someone followed you (public or approved)
|
follow, // Someone followed you
|
||||||
request_accepted, // Someone accepted your follow request
|
follow_request, // Someone requested to follow you
|
||||||
comment, // Someone commented on your post
|
follow_accepted, // Someone accepted your follow request
|
||||||
mention, // Someone mentioned you
|
message, // New chat message (if shown in notifications)
|
||||||
|
save, // Someone saved your post
|
||||||
|
beacon_vouch, // Someone vouched for your beacon
|
||||||
|
beacon_report, // Someone reported your beacon
|
||||||
|
share, // Someone shared your post
|
||||||
|
quip_reaction, // Someone reacted to your quip
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Notification model
|
/// Notification model
|
||||||
|
|
@ -115,22 +120,32 @@ class AppNotification {
|
||||||
String get message {
|
String get message {
|
||||||
final actorName = actor?.displayName ?? 'Someone';
|
final actorName = actor?.displayName ?? 'Someone';
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case NotificationType.appreciate:
|
case NotificationType.like:
|
||||||
return '$actorName appreciated your post';
|
return '$actorName liked your post';
|
||||||
case NotificationType.chain:
|
case NotificationType.reply:
|
||||||
return '$actorName chained your post';
|
return '$actorName replied to your post';
|
||||||
case NotificationType.follow:
|
case NotificationType.follow:
|
||||||
return '$actorName started following you';
|
return '$actorName started following you';
|
||||||
case NotificationType.follow_request:
|
case NotificationType.follow_request:
|
||||||
return '$actorName requested to follow you';
|
return '$actorName requested to follow you';
|
||||||
case NotificationType.new_follower:
|
case NotificationType.follow_accepted:
|
||||||
return '$actorName followed you';
|
|
||||||
case NotificationType.request_accepted:
|
|
||||||
return '$actorName accepted your follow request';
|
return '$actorName accepted your follow request';
|
||||||
case NotificationType.comment:
|
case NotificationType.comment:
|
||||||
return '$actorName commented on your post';
|
return '$actorName commented on your post';
|
||||||
case NotificationType.mention:
|
case NotificationType.mention:
|
||||||
return '$actorName mentioned you';
|
return '$actorName mentioned you';
|
||||||
|
case NotificationType.message:
|
||||||
|
return '$actorName sent you a message';
|
||||||
|
case NotificationType.save:
|
||||||
|
return '$actorName saved your post';
|
||||||
|
case NotificationType.beacon_vouch:
|
||||||
|
return '$actorName vouched for your beacon';
|
||||||
|
case NotificationType.beacon_report:
|
||||||
|
return '$actorName reported your beacon';
|
||||||
|
case NotificationType.share:
|
||||||
|
return '$actorName shared your post';
|
||||||
|
case NotificationType.quip_reaction:
|
||||||
|
return '$actorName reacted to your quip';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,9 +16,9 @@ class SearchUser {
|
||||||
|
|
||||||
factory SearchUser.fromJson(Map<String, dynamic> json) {
|
factory SearchUser.fromJson(Map<String, dynamic> json) {
|
||||||
return SearchUser(
|
return SearchUser(
|
||||||
id: json['id'] as String,
|
id: json['id'] as String? ?? '',
|
||||||
username: json['username'] as String,
|
username: (json['username'] as String?) ?? (json['handle'] as String?) ?? 'unknown',
|
||||||
displayName: json['display_name'] as String? ?? json['displayName'] as String? ?? json['username'] as String,
|
displayName: json['display_name'] as String? ?? json['displayName'] as String? ?? json['handle'] as String? ?? json['username'] as String? ?? 'Unknown',
|
||||||
avatarUrl: json['avatar_url'] as String?,
|
avatarUrl: json['avatar_url'] as String?,
|
||||||
harmonyTier: json['harmony_tier'] as String? ?? json['harmonyTier'] as String? ?? 'new',
|
harmonyTier: json['harmony_tier'] as String? ?? json['harmonyTier'] as String? ?? 'new',
|
||||||
);
|
);
|
||||||
|
|
@ -81,12 +81,15 @@ class SearchPost {
|
||||||
});
|
});
|
||||||
|
|
||||||
factory SearchPost.fromJson(Map<String, dynamic> json) {
|
factory SearchPost.fromJson(Map<String, dynamic> json) {
|
||||||
|
// Handle both flat structure and nested author object structure
|
||||||
|
final authorJson = json['author'] as Map<String, dynamic>?;
|
||||||
|
|
||||||
return SearchPost(
|
return SearchPost(
|
||||||
id: json['id'] as String,
|
id: json['id'] as String,
|
||||||
body: json['body'] as String,
|
body: json['body'] as String,
|
||||||
authorId: json['author_id'] as String,
|
authorId: json['author_id'] as String? ?? authorJson?['id'] as String? ?? '',
|
||||||
authorHandle: json['author_handle'] as String,
|
authorHandle: json['author_handle'] as String? ?? authorJson?['handle'] as String? ?? 'unknown',
|
||||||
authorDisplayName: json['author_display_name'] as String,
|
authorDisplayName: json['author_display_name'] as String? ?? authorJson?['display_name'] as String? ?? 'Unknown',
|
||||||
createdAt: DateTime.parse(json['created_at'] as String),
|
createdAt: DateTime.parse(json['created_at'] as String),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import '../screens/profile/blocked_users_screen.dart';
|
||||||
import '../screens/auth/auth_gate.dart';
|
import '../screens/auth/auth_gate.dart';
|
||||||
import '../screens/discover/discover_screen.dart';
|
import '../screens/discover/discover_screen.dart';
|
||||||
import '../screens/secure_chat/secure_chat_full_screen.dart';
|
import '../screens/secure_chat/secure_chat_full_screen.dart';
|
||||||
|
import '../screens/post/threaded_conversation_screen.dart';
|
||||||
|
|
||||||
/// App routing config (GoRouter).
|
/// App routing config (GoRouter).
|
||||||
class AppRoutes {
|
class AppRoutes {
|
||||||
|
|
@ -65,6 +66,13 @@ class AppRoutes {
|
||||||
parentNavigatorKey: rootNavigatorKey,
|
parentNavigatorKey: rootNavigatorKey,
|
||||||
builder: (_, __) => const SecureChatFullScreen(),
|
builder: (_, __) => const SecureChatFullScreen(),
|
||||||
),
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '$postPrefix/:id',
|
||||||
|
parentNavigatorKey: rootNavigatorKey,
|
||||||
|
builder: (_, state) => ThreadedConversationScreen(
|
||||||
|
rootPostId: state.pathParameters['id'] ?? '',
|
||||||
|
),
|
||||||
|
),
|
||||||
StatefulShellRoute.indexedStack(
|
StatefulShellRoute.indexedStack(
|
||||||
builder: (context, state, navigationShell) => AuthGate(
|
builder: (context, state, navigationShell) => AuthGate(
|
||||||
authenticatedChild: HomeShell(navigationShell: navigationShell),
|
authenticatedChild: HomeShell(navigationShell: navigationShell),
|
||||||
|
|
@ -81,16 +89,16 @@ class AppRoutes {
|
||||||
StatefulShellBranch(
|
StatefulShellBranch(
|
||||||
routes: [
|
routes: [
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/discover',
|
path: quips,
|
||||||
builder: (_, __) => const DiscoverScreen(),
|
builder: (_, __) => const QuipsFeedScreen(),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
StatefulShellBranch(
|
StatefulShellBranch(
|
||||||
routes: [
|
routes: [
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: quips,
|
path: '/beacon',
|
||||||
builder: (_, __) => const QuipsFeedScreen(),
|
builder: (_, __) => const BeaconScreen(),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -169,29 +177,29 @@ class AppRoutes {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get shareable URL for a user profile
|
/// Get shareable URL for a user profile
|
||||||
/// Returns: https://gosojorn.com/u/username
|
/// Returns: https://sojorn.net/u/username
|
||||||
static String getProfileUrl(
|
static String getProfileUrl(
|
||||||
String username, {
|
String username, {
|
||||||
String baseUrl = 'https://gosojorn.com',
|
String baseUrl = 'https://sojorn.net',
|
||||||
}) {
|
}) {
|
||||||
return '$baseUrl/u/$username';
|
return '$baseUrl/u/$username';
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get shareable URL for a post (future implementation)
|
/// Get shareable URL for a post (future implementation)
|
||||||
/// Returns: https://gosojorn.com/p/postid
|
/// Returns: https://sojorn.net/p/postid
|
||||||
static String getPostUrl(
|
static String getPostUrl(
|
||||||
String postId, {
|
String postId, {
|
||||||
String baseUrl = 'https://gosojorn.com',
|
String baseUrl = 'https://sojorn.net',
|
||||||
}) {
|
}) {
|
||||||
return '$baseUrl/p/$postId';
|
return '$baseUrl/p/$postId';
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get shareable URL for a beacon location
|
/// Get shareable URL for a beacon location
|
||||||
/// Returns: https://gosojorn.com/beacon?lat=...&long=...
|
/// Returns: https://sojorn.net/beacon?lat=...&long=...
|
||||||
static String getBeaconUrl(
|
static String getBeaconUrl(
|
||||||
double lat,
|
double lat,
|
||||||
double long, {
|
double long, {
|
||||||
String baseUrl = 'https://gosojorn.com',
|
String baseUrl = 'https://sojorn.net',
|
||||||
}) {
|
}) {
|
||||||
return '$baseUrl/beacon?lat=${lat.toStringAsFixed(6)}&long=${long.toStringAsFixed(6)}';
|
return '$baseUrl/beacon?lat=${lat.toStringAsFixed(6)}&long=${long.toStringAsFixed(6)}';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import '../../models/profile.dart';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
@ -81,9 +82,8 @@ class _DiscoverScreenState extends ConsumerState<DiscoverScreen> {
|
||||||
DiscoverData? discoverData;
|
DiscoverData? discoverData;
|
||||||
List<RecentSearch> recentSearches = [];
|
List<RecentSearch> recentSearches = [];
|
||||||
int _searchEpoch = 0;
|
int _searchEpoch = 0;
|
||||||
final Map<String, Future<Post>> _postFutures = {};
|
|
||||||
|
|
||||||
static const Duration debounceDuration = Duration(milliseconds: 250);
|
static const Duration debounceDuration = Duration(milliseconds: 300);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
|
|
@ -186,7 +186,9 @@ class _DiscoverScreenState extends ConsumerState<DiscoverScreen> {
|
||||||
}
|
}
|
||||||
|
|
||||||
debounceTimer = Timer(debounceDuration, () {
|
debounceTimer = Timer(debounceDuration, () {
|
||||||
performSearch(query);
|
if (query.length >= 2) {
|
||||||
|
performSearch(query);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -259,18 +261,6 @@ class _DiscoverScreenState extends ConsumerState<DiscoverScreen> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Post> _getPostFuture(String postId) {
|
|
||||||
return _postFutures.putIfAbsent(postId, () {
|
|
||||||
final apiService = ref.read(apiServiceProvider);
|
|
||||||
return apiService.getPostById(postId);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
void _retryPostLoad(String postId) {
|
|
||||||
_postFutures.remove(postId);
|
|
||||||
if (mounted) setState(() {});
|
|
||||||
}
|
|
||||||
|
|
||||||
void _openPostDetail(Post post) {
|
void _openPostDetail(Post post) {
|
||||||
Navigator.of(context, rootNavigator: true).push(
|
Navigator.of(context, rootNavigator: true).push(
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
|
|
@ -668,76 +658,35 @@ class _DiscoverScreenState extends ConsumerState<DiscoverScreen> {
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildPostResultItem(SearchPost post) {
|
Widget _buildPostResultItem(SearchPost post) {
|
||||||
return FutureBuilder<Post>(
|
// Convert SearchPost to minimal Post immediately
|
||||||
future: _getPostFuture(post.id),
|
final minimalPost = Post(
|
||||||
builder: (context, snapshot) {
|
id: post.id,
|
||||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
body: post.body,
|
||||||
return Container(
|
authorId: post.authorId,
|
||||||
margin: const EdgeInsets.only(bottom: 12),
|
createdAt: post.createdAt,
|
||||||
padding: const EdgeInsets.all(16),
|
status: PostStatus.active,
|
||||||
decoration: BoxDecoration(
|
detectedTone: ToneLabel.neutral,
|
||||||
color: AppTheme.cardSurface,
|
contentIntegrityScore: 0.0,
|
||||||
borderRadius: BorderRadius.circular(12),
|
author: Profile(
|
||||||
border: Border.all(color: AppTheme.egyptianBlue.withOpacity(0.2)),
|
id: post.authorId,
|
||||||
),
|
handle: post.authorHandle,
|
||||||
child: Row(
|
displayName: post.authorDisplayName,
|
||||||
children: [
|
createdAt: DateTime.now(),
|
||||||
SizedBox(
|
avatarUrl: null,
|
||||||
width: 20,
|
),
|
||||||
height: 20,
|
isLiked: false,
|
||||||
child: CircularProgressIndicator(
|
likeCount: 0,
|
||||||
strokeWidth: 2,
|
commentCount: 0,
|
||||||
color: AppTheme.royalPurple,
|
tags: [],
|
||||||
),
|
);
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Text('Loading post...', style: AppTheme.bodyMedium),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (snapshot.hasError || !snapshot.hasData) {
|
return Padding(
|
||||||
return Container(
|
padding: const EdgeInsets.only(bottom: 12),
|
||||||
margin: const EdgeInsets.only(bottom: 12),
|
child: sojornPostCard(
|
||||||
padding: const EdgeInsets.all(16),
|
post: minimalPost,
|
||||||
decoration: BoxDecoration(
|
onTap: () => _openPostDetail(minimalPost),
|
||||||
color: AppTheme.cardSurface,
|
onChain: () => _openChainComposer(minimalPost),
|
||||||
borderRadius: BorderRadius.circular(12),
|
),
|
||||||
border: Border.all(color: AppTheme.egyptianBlue.withOpacity(0.2)),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Icon(Icons.error_outline,
|
|
||||||
color: AppTheme.egyptianBlue.withOpacity(0.6)),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
'Unable to load post',
|
|
||||||
style: AppTheme.bodyMedium,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => _retryPostLoad(post.id),
|
|
||||||
child: Text('Retry',
|
|
||||||
style: AppTheme.labelMedium
|
|
||||||
.copyWith(color: AppTheme.royalPurple)),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
final fullPost = snapshot.data!;
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.only(bottom: 12),
|
|
||||||
child: sojornPostCard(
|
|
||||||
post: fullPost,
|
|
||||||
onTap: () => _openPostDetail(fullPost),
|
|
||||||
onChain: () => _openChainComposer(fullPost),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'dart:async';
|
||||||
|
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||||
import 'package:google_fonts/google_fonts.dart';
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
import '../../services/notification_service.dart';
|
||||||
import '../../services/secure_chat_service.dart';
|
import '../../services/secure_chat_service.dart';
|
||||||
import '../../theme/app_theme.dart';
|
import '../../theme/app_theme.dart';
|
||||||
import '../notifications/notifications_screen.dart';
|
import '../notifications/notifications_screen.dart';
|
||||||
|
|
@ -28,18 +31,29 @@ class HomeShell extends StatefulWidget {
|
||||||
class _HomeShellState extends State<HomeShell> with WidgetsBindingObserver {
|
class _HomeShellState extends State<HomeShell> with WidgetsBindingObserver {
|
||||||
bool _isRadialMenuVisible = false;
|
bool _isRadialMenuVisible = false;
|
||||||
final SecureChatService _chatService = SecureChatService();
|
final SecureChatService _chatService = SecureChatService();
|
||||||
|
StreamSubscription<RemoteMessage>? _notifSub;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
WidgetsBinding.instance.addObserver(this);
|
WidgetsBinding.instance.addObserver(this);
|
||||||
_chatService.startBackgroundSync();
|
_chatService.startBackgroundSync();
|
||||||
|
_initNotificationListener();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _initNotificationListener() {
|
||||||
|
_notifSub = NotificationService.instance.foregroundMessages.listen((message) {
|
||||||
|
if (mounted) {
|
||||||
|
NotificationService.instance.showNotificationBanner(context, message);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
WidgetsBinding.instance.removeObserver(this);
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
_chatService.stopBackgroundSync();
|
_chatService.stopBackgroundSync();
|
||||||
|
_notifSub?.cancel();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -171,17 +185,17 @@ class _HomeShellState extends State<HomeShell> with WidgetsBindingObserver {
|
||||||
label: 'Home',
|
label: 'Home',
|
||||||
),
|
),
|
||||||
_buildNavBarItem(
|
_buildNavBarItem(
|
||||||
icon: Icons.explore_outlined,
|
icon: Icons.play_circle_outline,
|
||||||
activeIcon: Icons.explore,
|
activeIcon: Icons.play_circle,
|
||||||
index: 1,
|
index: 1,
|
||||||
label: 'Discover',
|
label: 'Quips',
|
||||||
),
|
),
|
||||||
const SizedBox(width: 48),
|
const SizedBox(width: 48),
|
||||||
_buildNavBarItem(
|
_buildNavBarItem(
|
||||||
icon: Icons.play_circle_outline,
|
icon: Icons.sensors_outlined,
|
||||||
activeIcon: Icons.play_circle,
|
activeIcon: Icons.sensors,
|
||||||
index: 2,
|
index: 2,
|
||||||
label: 'Quips',
|
label: 'Beacon',
|
||||||
),
|
),
|
||||||
_buildNavBarItem(
|
_buildNavBarItem(
|
||||||
icon: Icons.person_outline,
|
icon: Icons.person_outline,
|
||||||
|
|
@ -222,7 +236,11 @@ class _HomeShellState extends State<HomeShell> with WidgetsBindingObserver {
|
||||||
icon: Icon(Icons.search, color: AppTheme.navyBlue),
|
icon: Icon(Icons.search, color: AppTheme.navyBlue),
|
||||||
tooltip: 'Discover',
|
tooltip: 'Discover',
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
widget.navigationShell.goBranch(1);
|
Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => const DiscoverScreen(),
|
||||||
|
),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
|
|
|
||||||
|
|
@ -243,8 +243,7 @@ class _NotificationsScreenState extends ConsumerState<NotificationsScreen> {
|
||||||
// Navigate based on notification type
|
// Navigate based on notification type
|
||||||
switch (notification.type) {
|
switch (notification.type) {
|
||||||
case NotificationType.follow:
|
case NotificationType.follow:
|
||||||
case NotificationType.new_follower:
|
case NotificationType.follow_accepted:
|
||||||
case NotificationType.request_accepted:
|
|
||||||
// Navigate to the follower's profile
|
// Navigate to the follower's profile
|
||||||
if (notification.actor != null) {
|
if (notification.actor != null) {
|
||||||
Navigator.of(context).push(
|
Navigator.of(context).push(
|
||||||
|
|
@ -255,9 +254,9 @@ class _NotificationsScreenState extends ConsumerState<NotificationsScreen> {
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case NotificationType.appreciate:
|
case NotificationType.like:
|
||||||
case NotificationType.comment:
|
case NotificationType.comment:
|
||||||
case NotificationType.chain:
|
case NotificationType.reply:
|
||||||
case NotificationType.mention:
|
case NotificationType.mention:
|
||||||
// Fetch the post and navigate to post detail
|
// Fetch the post and navigate to post detail
|
||||||
if (notification.postId != null) {
|
if (notification.postId != null) {
|
||||||
|
|
@ -284,8 +283,18 @@ class _NotificationsScreenState extends ConsumerState<NotificationsScreen> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
case NotificationType.message:
|
||||||
|
// For messages, navigate to chat screen
|
||||||
|
if (notification.metadata?['conversation_id'] != null) {
|
||||||
|
context.push('/secure-chat/${notification.metadata!['conversation_id']}');
|
||||||
|
} else {
|
||||||
|
context.push('/secure-chat');
|
||||||
|
}
|
||||||
|
break;
|
||||||
case NotificationType.follow_request:
|
case NotificationType.follow_request:
|
||||||
break;
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -492,20 +501,19 @@ class _NotificationItem extends StatelessWidget {
|
||||||
Color iconColor;
|
Color iconColor;
|
||||||
|
|
||||||
switch (notification.type) {
|
switch (notification.type) {
|
||||||
case NotificationType.appreciate:
|
case NotificationType.like:
|
||||||
iconData = Icons.favorite;
|
iconData = Icons.favorite;
|
||||||
iconColor = AppTheme.brightNavy;
|
iconColor = AppTheme.brightNavy;
|
||||||
break;
|
break;
|
||||||
case NotificationType.chain:
|
case NotificationType.reply:
|
||||||
iconData = Icons.subdirectory_arrow_right;
|
iconData = Icons.subdirectory_arrow_right;
|
||||||
iconColor = AppTheme.royalPurple;
|
iconColor = AppTheme.royalPurple;
|
||||||
break;
|
break;
|
||||||
case NotificationType.follow:
|
case NotificationType.follow:
|
||||||
case NotificationType.new_follower:
|
|
||||||
iconData = Icons.person_add;
|
iconData = Icons.person_add;
|
||||||
iconColor = AppTheme.ksuPurple;
|
iconColor = AppTheme.ksuPurple;
|
||||||
break;
|
break;
|
||||||
case NotificationType.request_accepted:
|
case NotificationType.follow_accepted:
|
||||||
iconData = Icons.check_circle;
|
iconData = Icons.check_circle;
|
||||||
iconColor = AppTheme.brightNavy;
|
iconColor = AppTheme.brightNavy;
|
||||||
break;
|
break;
|
||||||
|
|
@ -521,6 +529,18 @@ class _NotificationItem extends StatelessWidget {
|
||||||
iconData = Icons.person_add;
|
iconData = Icons.person_add;
|
||||||
iconColor = AppTheme.ksuPurple;
|
iconColor = AppTheme.ksuPurple;
|
||||||
break;
|
break;
|
||||||
|
case NotificationType.message:
|
||||||
|
iconData = Icons.message;
|
||||||
|
iconColor = AppTheme.egyptianBlue;
|
||||||
|
break;
|
||||||
|
case NotificationType.save:
|
||||||
|
iconData = Icons.bookmark;
|
||||||
|
iconColor = AppTheme.ksuPurple;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
iconData = Icons.notifications;
|
||||||
|
iconColor = AppTheme.egyptianBlue;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,9 @@ import '../../theme/app_theme.dart';
|
||||||
import '../../widgets/post/interactive_reply_block.dart';
|
import '../../widgets/post/interactive_reply_block.dart';
|
||||||
import '../../widgets/media/signed_media_image.dart';
|
import '../../widgets/media/signed_media_image.dart';
|
||||||
import '../compose/compose_screen.dart';
|
import '../compose/compose_screen.dart';
|
||||||
|
import '../discover/discover_screen.dart';
|
||||||
|
import '../secure_chat/secure_chat_full_screen.dart';
|
||||||
|
import 'package:share_plus/share_plus.dart';
|
||||||
|
|
||||||
class ThreadedConversationScreen extends ConsumerStatefulWidget {
|
class ThreadedConversationScreen extends ConsumerStatefulWidget {
|
||||||
final String rootPostId;
|
final String rootPostId;
|
||||||
|
|
@ -176,7 +179,31 @@ class _ThreadedConversationScreenState extends ConsumerState<ThreadedConversatio
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
actions: const [],
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
onPressed: () => context.go(AppRoutes.homeAlias),
|
||||||
|
icon: Icon(Icons.home_outlined, color: AppTheme.navyBlue),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
onPressed: () => Navigator.of(context, rootNavigator: true).push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => const DiscoverScreen(),
|
||||||
|
fullscreenDialog: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
icon: Icon(Icons.search, color: AppTheme.navyBlue),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
onPressed: () => Navigator.of(context, rootNavigator: true).push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => SecureChatFullScreen(),
|
||||||
|
fullscreenDialog: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
icon: Icon(Icons.chat_bubble_outline, color: AppTheme.navyBlue),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -583,9 +610,12 @@ class _ThreadedConversationScreenState extends ConsumerState<ThreadedConversatio
|
||||||
icon: const Icon(Icons.reply, size: 18),
|
icon: const Icon(Icons.reply, size: 18),
|
||||||
label: const Text('Reply'),
|
label: const Text('Reply'),
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: AppTheme.brightNavy,
|
backgroundColor: AppTheme.navyBlue.withValues(alpha: 0.05),
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: AppTheme.navyBlue,
|
||||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
elevation: 0,
|
||||||
|
shadowColor: Colors.transparent,
|
||||||
|
minimumSize: const Size(0, 44),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
),
|
||||||
|
|
@ -594,13 +624,14 @@ class _ThreadedConversationScreenState extends ConsumerState<ThreadedConversatio
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
IconButton(
|
IconButton(
|
||||||
onPressed: () => _toggleLike(post),
|
onPressed: () => _sharePost(post),
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
isLiked ? Icons.favorite : Icons.favorite_border,
|
Icons.share_outlined,
|
||||||
color: isLiked ? Colors.red : AppTheme.textSecondary,
|
color: AppTheme.textSecondary,
|
||||||
),
|
),
|
||||||
style: IconButton.styleFrom(
|
style: IconButton.styleFrom(
|
||||||
backgroundColor: AppTheme.navyBlue.withValues(alpha: 0.08),
|
backgroundColor: AppTheme.navyBlue.withValues(alpha: 0.05),
|
||||||
|
minimumSize: const Size(44, 44),
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
),
|
||||||
|
|
@ -614,7 +645,8 @@ class _ThreadedConversationScreenState extends ConsumerState<ThreadedConversatio
|
||||||
color: isSaved ? AppTheme.brightNavy : AppTheme.textSecondary,
|
color: isSaved ? AppTheme.brightNavy : AppTheme.textSecondary,
|
||||||
),
|
),
|
||||||
style: IconButton.styleFrom(
|
style: IconButton.styleFrom(
|
||||||
backgroundColor: AppTheme.navyBlue.withValues(alpha: 0.08),
|
backgroundColor: AppTheme.navyBlue.withValues(alpha: 0.05),
|
||||||
|
minimumSize: const Size(44, 44),
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
),
|
||||||
|
|
@ -996,20 +1028,14 @@ class _ThreadedConversationScreenState extends ConsumerState<ThreadedConversatio
|
||||||
}
|
}
|
||||||
|
|
||||||
void _seedReactionsForPost(Post post) {
|
void _seedReactionsForPost(Post post) {
|
||||||
print('DEBUG: _seedReactionsForPost for post ${post.id}');
|
|
||||||
print('DEBUG: post.reactions = ${post.reactions}');
|
|
||||||
print('DEBUG: post.myReactions = ${post.myReactions}');
|
|
||||||
|
|
||||||
if (post.reactions != null) {
|
if (post.reactions != null) {
|
||||||
_reactionCountsByPost.putIfAbsent(
|
_reactionCountsByPost.putIfAbsent(
|
||||||
post.id,
|
post.id,
|
||||||
() => Map<String, int>.from(post.reactions!),
|
() => Map<String, int>.from(post.reactions!),
|
||||||
);
|
);
|
||||||
print('DEBUG: Seeded reaction counts: ${_reactionCountsByPost[post.id]}');
|
|
||||||
}
|
}
|
||||||
if (post.myReactions != null) {
|
if (post.myReactions != null) {
|
||||||
_myReactionsByPost.putIfAbsent(post.id, () => post.myReactions!.toSet());
|
_myReactionsByPost.putIfAbsent(post.id, () => post.myReactions!.toSet());
|
||||||
print('DEBUG: Seeded my reactions: ${_myReactionsByPost[post.id]}');
|
|
||||||
}
|
}
|
||||||
if (post.reactionUsers != null) {
|
if (post.reactionUsers != null) {
|
||||||
_reactionUsersByPost.putIfAbsent(
|
_reactionUsersByPost.putIfAbsent(
|
||||||
|
|
@ -1020,19 +1046,12 @@ class _ThreadedConversationScreenState extends ConsumerState<ThreadedConversatio
|
||||||
}
|
}
|
||||||
|
|
||||||
Map<String, int> _reactionCountsFor(Post post) {
|
Map<String, int> _reactionCountsFor(Post post) {
|
||||||
// Debug: Check what we're getting from the post model
|
|
||||||
print('DEBUG: _reactionCountsFor for post ${post.id}');
|
|
||||||
print('DEBUG: post.reactions = ${post.reactions}');
|
|
||||||
print('DEBUG: _reactionCountsByPost[${post.id}] = ${_reactionCountsByPost[post.id]}');
|
|
||||||
|
|
||||||
// Prefer local state for immediate updates after toggle reactions
|
// Prefer local state for immediate updates after toggle reactions
|
||||||
final localState = _reactionCountsByPost[post.id];
|
final localState = _reactionCountsByPost[post.id];
|
||||||
if (localState != null) {
|
if (localState != null) {
|
||||||
print('DEBUG: Using local state: ${localState}');
|
|
||||||
return localState;
|
return localState;
|
||||||
}
|
}
|
||||||
// Fall back to post model if no local state
|
// Fall back to post model if no local state
|
||||||
print('DEBUG: Using post.reactions: ${post.reactions}');
|
|
||||||
return post.reactions ?? {};
|
return post.reactions ?? {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1063,22 +1082,16 @@ class _ThreadedConversationScreenState extends ConsumerState<ThreadedConversatio
|
||||||
final updatedCounts = response['reactions'] as Map<String, dynamic>?;
|
final updatedCounts = response['reactions'] as Map<String, dynamic>?;
|
||||||
final updatedMine = response['my_reactions'] as List<dynamic>?;
|
final updatedMine = response['my_reactions'] as List<dynamic>?;
|
||||||
|
|
||||||
print('DEBUG: Toggle reaction response: $response');
|
|
||||||
print('DEBUG: updatedCounts: $updatedCounts');
|
|
||||||
print('DEBUG: updatedMine: $updatedMine');
|
|
||||||
|
|
||||||
if (updatedCounts != null) {
|
if (updatedCounts != null) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_reactionCountsByPost[postId] = updatedCounts
|
_reactionCountsByPost[postId] = updatedCounts
|
||||||
.map((key, value) => MapEntry(key, value as int));
|
.map((key, value) => MapEntry(key, value as int));
|
||||||
print('DEBUG: Updated local reaction counts: ${_reactionCountsByPost[postId]}');
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (updatedMine != null) {
|
if (updatedMine != null) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_myReactionsByPost[postId] =
|
_myReactionsByPost[postId] =
|
||||||
updatedMine.map((item) => item.toString()).toSet();
|
updatedMine.map((item) => item.toString()).toSet();
|
||||||
print('DEBUG: Updated local my reactions: ${_myReactionsByPost[postId]}');
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
|
|
@ -1161,4 +1174,19 @@ class _ThreadedConversationScreenState extends ConsumerState<ThreadedConversatio
|
||||||
if (trimmed.isEmpty) return 'S';
|
if (trimmed.isEmpty) return 'S';
|
||||||
return trimmed.characters.first.toUpperCase();
|
return trimmed.characters.first.toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _sharePost(Post post) async {
|
||||||
|
final handle = post.author?.handle ?? 'sojorn';
|
||||||
|
final text = '${post.body}\n\n— @$handle on sojorn';
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Share.share(text);
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Unable to share right now.')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ class ProfileScreen extends ConsumerStatefulWidget {
|
||||||
String _resolveAvatar(String? url) {
|
String _resolveAvatar(String? url) {
|
||||||
if (url == null || url.isEmpty) return '';
|
if (url == null || url.isEmpty) return '';
|
||||||
if (url.startsWith('http://') || url.startsWith('https://')) return url;
|
if (url.startsWith('http://') || url.startsWith('https://')) return url;
|
||||||
return 'https://img.gosojorn.com/${url.replaceFirst(RegExp('^/'), '')}';
|
return 'https://img.sojorn.net/${url.replaceFirst(RegExp('^/'), '')}';
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ProfileScreenState extends ConsumerState<ProfileScreen>
|
class _ProfileScreenState extends ConsumerState<ProfileScreen>
|
||||||
|
|
|
||||||
|
|
@ -90,7 +90,7 @@ class _QuipsFeedScreenState extends ConsumerState<QuipsFeedScreen>
|
||||||
bool _isUserPaused = false;
|
bool _isUserPaused = false;
|
||||||
int _lastRefreshToken = 0;
|
int _lastRefreshToken = 0;
|
||||||
|
|
||||||
static const int _branchIndex = 2;
|
static const int _branchIndex = 1;
|
||||||
static const int _pageSize = 8;
|
static const int _pageSize = 8;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import '../../models/profile.dart';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
@ -31,14 +32,12 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
||||||
SearchResults? results;
|
SearchResults? results;
|
||||||
List<RecentSearch> recentSearches = [];
|
List<RecentSearch> recentSearches = [];
|
||||||
int _searchEpoch = 0;
|
int _searchEpoch = 0;
|
||||||
|
|
||||||
final Map<String, Future<Post>> _postFutures = {};
|
|
||||||
|
|
||||||
// Discovery State
|
// Discovery State
|
||||||
bool _isDiscoveryLoading = false;
|
bool _isDiscoveryLoading = false;
|
||||||
List<Post> _discoveryPosts = [];
|
List<Post> _discoveryPosts = [];
|
||||||
|
|
||||||
static const Duration debounceDuration = Duration(milliseconds: 250);
|
static const Duration debounceDuration = Duration(milliseconds: 300);
|
||||||
static const List<String> trendingTags = [
|
static const List<String> trendingTags = [
|
||||||
'safety',
|
'safety',
|
||||||
'wellness',
|
'wellness',
|
||||||
|
|
@ -155,7 +154,9 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
||||||
}
|
}
|
||||||
|
|
||||||
debounceTimer = Timer(debounceDuration, () {
|
debounceTimer = Timer(debounceDuration, () {
|
||||||
performSearch(query);
|
if (query.length >= 2) {
|
||||||
|
performSearch(query);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -170,9 +171,15 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
print('[SearchScreen] Requesting search for: "$normalizedQuery"');
|
||||||
final apiService = ref.read(apiServiceProvider);
|
final apiService = ref.read(apiServiceProvider);
|
||||||
final searchResults = await apiService.search(normalizedQuery);
|
final searchResults = await apiService.search(normalizedQuery);
|
||||||
if (!mounted || requestId != _searchEpoch) return;
|
if (!mounted || requestId != _searchEpoch) {
|
||||||
|
print('[SearchScreen] Request $requestId discarded (stale)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
print('[SearchScreen] Results received. Users: ${searchResults.users.length}, Tags: ${searchResults.tags.length}, Posts: ${searchResults.posts.length}');
|
||||||
|
|
||||||
if (searchResults.users.isNotEmpty) {
|
if (searchResults.users.isNotEmpty) {
|
||||||
await saveRecentSearch(RecentSearch(
|
await saveRecentSearch(RecentSearch(
|
||||||
|
|
@ -196,6 +203,7 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
||||||
isLoading = false;
|
isLoading = false;
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
print('[SearchScreen] Search error: $e');
|
||||||
if (!mounted || requestId != _searchEpoch) return;
|
if (!mounted || requestId != _searchEpoch) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
isLoading = false;
|
isLoading = false;
|
||||||
|
|
@ -214,18 +222,6 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
||||||
focusNode.requestFocus();
|
focusNode.requestFocus();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Post> _getPostFuture(String postId) {
|
|
||||||
return _postFutures.putIfAbsent(postId, () {
|
|
||||||
final apiService = ref.read(apiServiceProvider);
|
|
||||||
return apiService.getPostById(postId);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
void _retryPostLoad(String postId) {
|
|
||||||
_postFutures.remove(postId);
|
|
||||||
if (mounted) setState(() {});
|
|
||||||
}
|
|
||||||
|
|
||||||
void _openPostDetail(Post post) {
|
void _openPostDetail(Post post) {
|
||||||
Navigator.of(context, rootNavigator: true).push(
|
Navigator.of(context, rootNavigator: true).push(
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
|
|
@ -588,85 +584,49 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget buildPostResultItem(SearchPost post) {
|
Widget buildPostResultItem(SearchPost post) {
|
||||||
return FutureBuilder<Post>(
|
// Convert SearchPost to minimal Post immediately
|
||||||
future: _getPostFuture(post.id),
|
final minimalPost = Post(
|
||||||
builder: (context, snapshot) {
|
id: post.id,
|
||||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
body: post.body,
|
||||||
return Container(
|
authorId: post.authorId,
|
||||||
margin: const EdgeInsets.only(bottom: 12),
|
createdAt: post.createdAt,
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
decoration: BoxDecoration(
|
// REQUIRED fields missing previously
|
||||||
color: AppTheme.cardSurface,
|
status: PostStatus.active,
|
||||||
borderRadius: BorderRadius.circular(12),
|
detectedTone: ToneLabel.neutral,
|
||||||
border: Border.all(color: AppTheme.egyptianBlue.withOpacity(0.2)),
|
contentIntegrityScore: 0.0,
|
||||||
),
|
|
||||||
child: Row(
|
author: Profile(
|
||||||
children: [
|
id: post.authorId,
|
||||||
SizedBox(
|
handle: post.authorHandle,
|
||||||
width: 20,
|
displayName: post.authorDisplayName,
|
||||||
height: 20,
|
createdAt: DateTime.now(),
|
||||||
child: CircularProgressIndicator(
|
avatarUrl: null,
|
||||||
strokeWidth: 2,
|
),
|
||||||
color: AppTheme.royalPurple,
|
// Set defaults for rest
|
||||||
),
|
isLiked: false,
|
||||||
),
|
likeCount: 0,
|
||||||
const SizedBox(width: 12),
|
commentCount: 0,
|
||||||
Text('Loading post...', style: AppTheme.bodyMedium),
|
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 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),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
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);
|
final sanitizedQuery = SecurityUtils.limitText(SecurityUtils.sanitizeText(query), maxLength: 100);
|
||||||
|
|
||||||
if (!SecurityUtils.isValidInput(sanitizedQuery)) {
|
if (!SecurityUtils.isValidInput(sanitizedQuery)) {
|
||||||
|
if (kDebugMode) print('[API] Invalid search query input: $query');
|
||||||
return SearchResults(users: [], tags: [], posts: []);
|
return SearchResults(users: [], tags: [], posts: []);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (kDebugMode) print('[API] Searching for: $sanitizedQuery');
|
||||||
final data = await callGoApi(
|
final data = await callGoApi(
|
||||||
'/search',
|
'/search',
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
queryParams: {'q': sanitizedQuery},
|
queryParams: {'q': sanitizedQuery},
|
||||||
);
|
);
|
||||||
|
// if (kDebugMode) print('[API] Search raw response: ${jsonEncode(data)}');
|
||||||
return SearchResults.fromJson(data);
|
return SearchResults.fromJson(data);
|
||||||
} catch (_) {
|
} catch (e, stack) {
|
||||||
|
if (kDebugMode) {
|
||||||
|
print('[API] Search failed for query: "$query"');
|
||||||
|
print('Error: $e');
|
||||||
|
print('Stack: $stack');
|
||||||
|
}
|
||||||
// Return empty results on error
|
// Return empty results on error
|
||||||
return SearchResults(users: [], tags: [], posts: []);
|
return SearchResults(users: [], tags: [], posts: []);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -505,13 +505,13 @@ class ImageUploadService {
|
||||||
// Fix Image URLs
|
// Fix Image URLs
|
||||||
if (url.contains('/sojorn-media/')) {
|
if (url.contains('/sojorn-media/')) {
|
||||||
final key = url.split('/sojorn-media/').last;
|
final key = url.split('/sojorn-media/').last;
|
||||||
return 'https://img.gosojorn.com/$key';
|
return 'https://img.sojorn.net/$key';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fix Video URLs
|
// Fix Video URLs
|
||||||
if (url.contains('/sojorn-videos/')) {
|
if (url.contains('/sojorn-videos/')) {
|
||||||
final key = url.split('/sojorn-videos/').last;
|
final key = url.split('/sojorn-videos/').last;
|
||||||
return 'https://quips.gosojorn.com/$key';
|
return 'https://quips.sojorn.net/$key';
|
||||||
}
|
}
|
||||||
|
|
||||||
return url;
|
return url;
|
||||||
|
|
|
||||||
|
|
@ -531,7 +531,7 @@ class NotificationService {
|
||||||
case 'thread_view':
|
case 'thread_view':
|
||||||
case 'main_feed':
|
case 'main_feed':
|
||||||
default:
|
default:
|
||||||
navigator.context.go(AppRoutes.home);
|
navigator.context.push('${AppRoutes.postPrefix}/$postId');
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ class LinkHandler {
|
||||||
Uri? uri = Uri.tryParse(url.replaceFirst('sojorn://', 'sojorn://'));
|
Uri? uri = Uri.tryParse(url.replaceFirst('sojorn://', 'sojorn://'));
|
||||||
// Normalize to https for query parsing if needed
|
// Normalize to https for query parsing if needed
|
||||||
uri ??=
|
uri ??=
|
||||||
Uri.tryParse(url.replaceFirst('sojorn://', 'https://gosojorn.com/'));
|
Uri.tryParse(url.replaceFirst('sojorn://', 'https://sojorn.net/'));
|
||||||
|
|
||||||
final latParam = uri?.queryParameters['lat'];
|
final latParam = uri?.queryParameters['lat'];
|
||||||
final longParam = uri?.queryParameters['long'];
|
final longParam = uri?.queryParameters['long'];
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ class UrlLauncherHelper {
|
||||||
// List of known safe domains
|
// List of known safe domains
|
||||||
static const List<String> _safeDomains = [
|
static const List<String> _safeDomains = [
|
||||||
'mp.ls', 'www.mp.ls', 'patrick.mp.ls'
|
'mp.ls', 'www.mp.ls', 'patrick.mp.ls'
|
||||||
'gosojorn.com', 'www.gosojorn.com'
|
'sojorn.net', 'www.sojorn.net'
|
||||||
'youtube.com', 'www.youtube.com', 'youtu.be',
|
'youtube.com', 'www.youtube.com', 'youtu.be',
|
||||||
'instagram.com', 'www.instagram.com',
|
'instagram.com', 'www.instagram.com',
|
||||||
'twitter.com', 'www.twitter.com', 'x.com', 'www.x.com',
|
'twitter.com', 'www.twitter.com', 'x.com', 'www.x.com',
|
||||||
|
|
|
||||||
|
|
@ -70,12 +70,12 @@ class _SignedMediaImageState extends ConsumerState<SignedMediaImage> {
|
||||||
final host = uri.host.toLowerCase();
|
final host = uri.host.toLowerCase();
|
||||||
|
|
||||||
// Custom domain URLs are public and directly accessible - no signing needed
|
// Custom domain URLs are public and directly accessible - no signing needed
|
||||||
if (host == 'img.gosojorn.com' || host == 'quips.gosojorn.com') {
|
if (host == 'img.sojorn.net' || host == 'quips.sojorn.net') {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Legacy: media.gosojorn.com might need signing depending on setup
|
// Legacy: media.sojorn.net might need signing depending on setup
|
||||||
if (host == 'media.gosojorn.com') {
|
if (host == 'media.sojorn.net') {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -278,11 +278,13 @@ class _PostActionsState extends ConsumerState<PostActions> {
|
||||||
if (allowChain)
|
if (allowChain)
|
||||||
ElevatedButton.icon(
|
ElevatedButton.icon(
|
||||||
onPressed: widget.onChain,
|
onPressed: widget.onChain,
|
||||||
icon: const Icon(Icons.reply, size: 18),
|
icon: Icon(Icons.reply, size: 18, color: AppTheme.navyBlue),
|
||||||
label: const Text('Reply'),
|
label: Text('Reply', style: TextStyle(color: AppTheme.navyBlue, fontWeight: FontWeight.w600)),
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: AppTheme.brightNavy,
|
backgroundColor: AppTheme.navyBlue.withValues(alpha: 0.05),
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: AppTheme.navyBlue,
|
||||||
|
elevation: 0,
|
||||||
|
shadowColor: Colors.transparent,
|
||||||
minimumSize: const Size(0, 44),
|
minimumSize: const Size(0, 44),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ import '../../routes/app_routes.dart';
|
||||||
String _resolveAvatarUrl(String? url) {
|
String _resolveAvatarUrl(String? url) {
|
||||||
if (url == null || url.isEmpty) return '';
|
if (url == null || url.isEmpty) return '';
|
||||||
if (url.startsWith('http://') || url.startsWith('https://')) return url;
|
if (url.startsWith('http://') || url.startsWith('https://')) return url;
|
||||||
return 'https://img.gosojorn.com/${url.replaceFirst(RegExp('^/'), '')}';
|
return 'https://img.sojorn.net/${url.replaceFirst(RegExp('^/'), '')}';
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Post header with author info and timestamp.
|
/// Post header with author info and timestamp.
|
||||||
|
|
|
||||||
|
|
@ -54,11 +54,11 @@ class ReactionsDisplay extends StatelessWidget {
|
||||||
return Row(
|
return Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
if (onAddReaction != null) ...[
|
|
||||||
_ReactionAddButton(onTap: onAddReaction!),
|
|
||||||
if (reactionCounts.isNotEmpty) const SizedBox(width: 8),
|
|
||||||
],
|
|
||||||
if (reactionCounts.isNotEmpty) _buildTopReactionChip(),
|
if (reactionCounts.isNotEmpty) _buildTopReactionChip(),
|
||||||
|
if (onAddReaction != null) ...[
|
||||||
|
if (reactionCounts.isNotEmpty) const SizedBox(width: 8),
|
||||||
|
_ReactionAddButton(onTap: onAddReaction!),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -95,6 +95,8 @@ class ReactionsDisplay extends StatelessWidget {
|
||||||
runSpacing: 8,
|
runSpacing: 8,
|
||||||
crossAxisAlignment: WrapCrossAlignment.center,
|
crossAxisAlignment: WrapCrossAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
|
if (onAddReaction != null)
|
||||||
|
_ReactionAddButton(onTap: onAddReaction!),
|
||||||
...sortedEntries.map((entry) {
|
...sortedEntries.map((entry) {
|
||||||
return _ReactionChip(
|
return _ReactionChip(
|
||||||
reactionId: entry.key,
|
reactionId: entry.key,
|
||||||
|
|
@ -105,8 +107,6 @@ class ReactionsDisplay extends StatelessWidget {
|
||||||
onLongPress: onAddReaction,
|
onLongPress: onAddReaction,
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
if (onAddReaction != null)
|
|
||||||
_ReactionAddButton(onTap: onAddReaction!),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -5,4 +5,4 @@ echo Starting Sojorn on Chrome...
|
||||||
echo.
|
echo.
|
||||||
|
|
||||||
flutter run -d chrome ^
|
flutter run -d chrome ^
|
||||||
--dart-define=API_BASE_URL=https://api.gosojorn.com/api/v1
|
--dart-define=API_BASE_URL=https://api.sojorn.net/api/v1
|
||||||
|
|
|
||||||
|
|
@ -5,4 +5,4 @@ echo Starting Sojorn in development mode...
|
||||||
echo.
|
echo.
|
||||||
|
|
||||||
flutter run ^
|
flutter run ^
|
||||||
--dart-define=API_BASE_URL=https://api.gosojorn.com/api/v1
|
--dart-define=API_BASE_URL=https://api.sojorn.net/api/v1
|
||||||
|
|
|
||||||
|
|
@ -197,7 +197,7 @@ for _, origin := range allowedOrigins {
|
||||||
### Zero Downtime Approach
|
### Zero Downtime Approach
|
||||||
|
|
||||||
1. **Parallel Run**: Both Supabase and Go VPS running simultaneously
|
1. **Parallel Run**: Both Supabase and Go VPS running simultaneously
|
||||||
2. **DNS Update**: Point `api.gosojorn.com` to new VPS IP
|
2. **DNS Update**: Point `api.sojorn.net` to new VPS IP
|
||||||
3. **TTL Management**: Set DNS TTL to 300s before cutover
|
3. **TTL Management**: Set DNS TTL to 300s before cutover
|
||||||
4. **Monitoring**: Real-time log monitoring for errors
|
4. **Monitoring**: Real-time log monitoring for errors
|
||||||
|
|
||||||
|
|
@ -212,7 +212,7 @@ for _, origin := range allowedOrigins {
|
||||||
|
|
||||||
**DNS Switch:**
|
**DNS Switch:**
|
||||||
```bash
|
```bash
|
||||||
# Update A record for api.gosojorn.com
|
# Update A record for api.sojorn.net
|
||||||
# Monitor propagation
|
# Monitor propagation
|
||||||
# Watch error rates
|
# Watch error rates
|
||||||
```
|
```
|
||||||
|
|
@ -223,7 +223,7 @@ for _, origin := range allowedOrigins {
|
||||||
journalctl -u sojorn-api -f
|
journalctl -u sojorn-api -f
|
||||||
|
|
||||||
# Check error rates
|
# Check error rates
|
||||||
curl -s https://api.gosojorn.com/health
|
curl -s https://api.sojorn.net/health
|
||||||
|
|
||||||
# Validate data integrity
|
# Validate data integrity
|
||||||
sudo -u postgres psql sojorn -c "SELECT COUNT(*) FROM users;"
|
sudo -u postgres psql sojorn -c "SELECT COUNT(*) FROM users;"
|
||||||
|
|
@ -329,7 +329,7 @@ sudo -u postgres psql sojorn -c "SELECT COUNT(*) FROM users;"
|
||||||
|
|
||||||
### Emergency Rollback Procedure
|
### Emergency Rollback Procedure
|
||||||
|
|
||||||
1. **DNS Reversion**: Point `api.gosojorn.com` back to Supabase
|
1. **DNS Reversion**: Point `api.sojorn.net` back to Supabase
|
||||||
2. **Data Sync**: Restore any new data from Go backend to Supabase
|
2. **Data Sync**: Restore any new data from Go backend to Supabase
|
||||||
3. **Service Restart**: Restart Supabase Edge Functions
|
3. **Service Restart**: Restart Supabase Edge Functions
|
||||||
4. **Client Update**: Update Flutter app configuration if needed
|
4. **Client Update**: Update Flutter app configuration if needed
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@
|
||||||
|
|
||||||
This guide consolidates all deployment, operations, and maintenance documentation for the Sojorn platform, covering infrastructure setup, deployment procedures, monitoring, and operational best practices.
|
This guide consolidates all deployment, operations, and maintenance documentation for the Sojorn platform, covering infrastructure setup, deployment procedures, monitoring, and operational best practices.
|
||||||
|
|
||||||
|
All code updates need to be made locally, synced to git, pulled to the server and deployed. Do not directly edit files on the server.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Infrastructure Setup
|
## Infrastructure Setup
|
||||||
|
|
@ -136,19 +138,19 @@ FIREBASE_CREDENTIALS_FILE=/opt/sojorn/firebase-service-account.json
|
||||||
FIREBASE_WEB_VAPID_KEY=BNxS7_your_vapid_key_here
|
FIREBASE_WEB_VAPID_KEY=BNxS7_your_vapid_key_here
|
||||||
|
|
||||||
# Storage
|
# Storage
|
||||||
R2_ACCOUNT_ID=your-r2-account-id
|
SENDER_API_TOKEN=eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJhdWQiOiIxIiwianRpIjoiNT>
|
||||||
R2_ACCESS_KEY_ID=your-access-key
|
R2_ACCOUNT_ID=7041ca6e0f40307190dc2e65e2fb5e0f
|
||||||
R2_SECRET_ACCESS_KEY=your-secret-key
|
R2_PUBLIC_BASE_URL=http://api.sojorn.net:8080/uploads
|
||||||
R2_BUCKET_NAME=sojorn-uploads
|
R2_IMG_DOMAIN=img.sojorn.net
|
||||||
R2_PUBLIC_BASE_URL=https://pub-xxxxx.r2.dev
|
R2_VID_DOMAIN=quips.sojorn.net
|
||||||
R2_IMG_DOMAIN=img.sojorn.com
|
R2_API_TOKEN=oR7Vk0Realtx0D6SAGMuYA8pXopSoCYKv8t3JEuk
|
||||||
R2_VID_DOMAIN=vid.sojorn.com
|
|
||||||
|
|
||||||
# Email (Optional)
|
# Email (Optional)
|
||||||
SMTP_HOST=smtp.gmail.com
|
# Email / SendPulse
|
||||||
|
SMTP_HOST=smtp-pulse.com
|
||||||
SMTP_PORT=587
|
SMTP_PORT=587
|
||||||
SMTP_USER=noreply@sojorn.com
|
SMTP_USER=patrickbritton3@gmail.com
|
||||||
SMTP_PASS=your-app-password
|
SMTP_PASS=8s4jQBnAFTCXPNM
|
||||||
|
|
||||||
# Logging
|
# Logging
|
||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
|
|
@ -245,7 +247,7 @@ Create `/etc/nginx/sites-available/sojorn-api`:
|
||||||
```nginx
|
```nginx
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
server_name api.gosojorn.com;
|
server_name api.sojorn.net;
|
||||||
|
|
||||||
# Redirect to HTTPS
|
# Redirect to HTTPS
|
||||||
return 301 https://$server_name$request_uri;
|
return 301 https://$server_name$request_uri;
|
||||||
|
|
@ -253,11 +255,11 @@ server {
|
||||||
|
|
||||||
server {
|
server {
|
||||||
listen 443 ssl http2;
|
listen 443 ssl http2;
|
||||||
server_name api.gosojorn.com;
|
server_name api.sojorn.net;
|
||||||
|
|
||||||
# SSL Configuration
|
# SSL Configuration
|
||||||
ssl_certificate /etc/letsencrypt/live/api.gosojorn.com/fullchain.pem;
|
ssl_certificate /etc/letsencrypt/live/api.sojorn.net/fullchain.pem;
|
||||||
ssl_certificate_key /etc/letsencrypt/live/api.gosojorn.com/privkey.pem;
|
ssl_certificate_key /etc/letsencrypt/live/api.sojorn.net/privkey.pem;
|
||||||
ssl_protocols TLSv1.2 TLSv1.3;
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
ssl_ciphers ECDHE-RSA-AES256-GCM-SHA512:DHE-RSA-AES256-GCM-SHA512:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES256-GCM-SHA384;
|
ssl_ciphers ECDHE-RSA-AES256-GCM-SHA512:DHE-RSA-AES256-GCM-SHA512:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES256-GCM-SHA384;
|
||||||
ssl_prefer_server_ciphers off;
|
ssl_prefer_server_ciphers off;
|
||||||
|
|
@ -346,7 +348,7 @@ sudo systemctl reload nginx
|
||||||
sudo apt install certbot python3-certbot-nginx
|
sudo apt install certbot python3-certbot-nginx
|
||||||
|
|
||||||
# Obtain certificate
|
# Obtain certificate
|
||||||
sudo certbot --nginx -d api.gosojorn.com
|
sudo certbot --nginx -d api.sojorn.net
|
||||||
|
|
||||||
# Test renewal
|
# Test renewal
|
||||||
sudo certbot renew --dry-run
|
sudo certbot renew --dry-run
|
||||||
|
|
@ -556,7 +558,7 @@ psql -h localhost -U sojorn_user -d sojorn
|
||||||
sudo systemctl start sojorn-api
|
sudo systemctl start sojorn-api
|
||||||
|
|
||||||
# Verify recovery
|
# Verify recovery
|
||||||
curl -f https://api.gosojorn.com/health
|
curl -f https://api.sojorn.net/health
|
||||||
```
|
```
|
||||||
|
|
||||||
#### File Recovery
|
#### File Recovery
|
||||||
|
|
@ -939,7 +941,7 @@ sudo systemctl reload nginx
|
||||||
#### Problem: SSL handshake failed
|
#### Problem: SSL handshake failed
|
||||||
```bash
|
```bash
|
||||||
# Test SSL configuration
|
# Test SSL configuration
|
||||||
openssl s_client -connect api.gosojorn.com:443
|
openssl s_client -connect api.sojorn.net:443
|
||||||
|
|
||||||
# Check Nginx configuration
|
# Check Nginx configuration
|
||||||
sudo nginx -t
|
sudo nginx -t
|
||||||
|
|
@ -1003,7 +1005,7 @@ sudo systemctl reload nginx
|
||||||
sudo systemctl restart postgresql
|
sudo systemctl restart postgresql
|
||||||
|
|
||||||
# Verify recovery
|
# Verify recovery
|
||||||
curl -f https://api.gosojorn.com/health
|
curl -f https://api.sojorn.net/health
|
||||||
```
|
```
|
||||||
|
|
||||||
3. **Communication**
|
3. **Communication**
|
||||||
|
|
|
||||||
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
|
### API Configuration
|
||||||
The app connects to the production API at:
|
The app connects to the production API at:
|
||||||
```
|
```
|
||||||
https://api.gosojorn.com (or http://194.238.28.122:8080)
|
https://api.sojorn.net (or http://194.238.28.122:8080)
|
||||||
```
|
```
|
||||||
|
|
||||||
Configuration is in: `lib/config/api_config.dart`
|
Configuration is in: `lib/config/api_config.dart`
|
||||||
|
|
@ -140,7 +140,7 @@ R2_SECRET_KEY=...
|
||||||
| `internal/handlers/user_handler.go` | Profile & social endpoints |
|
| `internal/handlers/user_handler.go` | Profile & social endpoints |
|
||||||
| `internal/repository/post_repository.go` | Post database queries |
|
| `internal/repository/post_repository.go` | Post database queries |
|
||||||
| `internal/repository/user_repository.go` | User database queries |
|
| `internal/repository/user_repository.go` | User database queries |
|
||||||
|
aaa
|
||||||
---
|
---
|
||||||
|
|
||||||
## Server Deployment
|
## Server Deployment
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,7 @@ token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error)
|
||||||
**Verification**:
|
**Verification**:
|
||||||
```bash
|
```bash
|
||||||
# Test JWT validation
|
# Test JWT validation
|
||||||
curl -H "Authorization: Bearer <token>" https://api.gosojorn.com/health
|
curl -H "Authorization: Bearer <token>" https://api.sojorn.net/health
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -513,7 +513,7 @@ sudo certbot certificates
|
||||||
sudo nginx -t
|
sudo nginx -t
|
||||||
|
|
||||||
# Check certificate expiry
|
# Check certificate expiry
|
||||||
openssl x509 -in /etc/letsencrypt/live/api.gosojorn.com/cert.pem -text -noout | grep "Not After"
|
openssl x509 -in /etc/letsencrypt/live/api.sojorn.net/cert.pem -text -noout | grep "Not After"
|
||||||
```
|
```
|
||||||
|
|
||||||
**Solutions**:
|
**Solutions**:
|
||||||
|
|
@ -527,8 +527,8 @@ sudo systemctl reload nginx
|
||||||
|
|
||||||
#### 2. Fix Nginx SSL Config
|
#### 2. Fix Nginx SSL Config
|
||||||
```nginx
|
```nginx
|
||||||
ssl_certificate /etc/letsencrypt/live/api.gosojorn.com/fullchain.pem;
|
ssl_certificate /etc/letsencrypt/live/api.sojorn.net/fullchain.pem;
|
||||||
ssl_certificate_key /etc/letsencrypt/live/api.gosojorn.com/privkey.pem;
|
ssl_certificate_key /etc/letsencrypt/live/api.sojorn.net/privkey.pem;
|
||||||
```
|
```
|
||||||
|
|
||||||
### DNS Propagation Issues
|
### DNS Propagation Issues
|
||||||
|
|
@ -541,11 +541,11 @@ ssl_certificate_key /etc/letsencrypt/live/api.gosojorn.com/privkey.pem;
|
||||||
**Diagnostics**:
|
**Diagnostics**:
|
||||||
```bash
|
```bash
|
||||||
# Check DNS resolution
|
# Check DNS resolution
|
||||||
nslookup api.gosojorn.com
|
nslookup api.sojorn.net
|
||||||
dig api.gosojorn.com
|
dig api.sojorn.net
|
||||||
|
|
||||||
# Check propagation
|
# Check propagation
|
||||||
for i in {1..10}; do echo "Attempt $i:"; dig api.gosojorn.com +short; sleep 30; done
|
for i in {1..10}; do echo "Attempt $i:"; dig api.sojorn.net +short; sleep 30; done
|
||||||
```
|
```
|
||||||
|
|
||||||
**Solutions**:
|
**Solutions**:
|
||||||
|
|
@ -553,11 +553,11 @@ for i in {1..10}; do echo "Attempt $i:"; dig api.gosojorn.com +short; sleep 30;
|
||||||
#### 1. Verify DNS Records
|
#### 1. Verify DNS Records
|
||||||
```bash
|
```bash
|
||||||
# Check A record
|
# Check A record
|
||||||
dig api.gosojorn.com A
|
dig api.sojorn.net A
|
||||||
|
|
||||||
# Check with multiple DNS servers
|
# Check with multiple DNS servers
|
||||||
dig @8.8.8.8 api.gosojorn.com
|
dig @8.8.8.8 api.sojorn.net
|
||||||
dig @1.1.1.1 api.gosojorn.com
|
dig @1.1.1.1 api.sojorn.net
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 2. Reduce TTL Before Changes
|
#### 2. Reduce TTL Before Changes
|
||||||
|
|
@ -581,7 +581,7 @@ sudo -u postgres psql -c "SELECT count(*) FROM users;"
|
||||||
|
|
||||||
# Network
|
# Network
|
||||||
sudo netstat -tlnp | grep :8080
|
sudo netstat -tlnp | grep :8080
|
||||||
curl -I https://api.gosojorn.com/health
|
curl -I https://api.sojorn.net/health
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
sudo tail -f /var/log/nginx/access.log
|
sudo tail -f /var/log/nginx/access.log
|
||||||
|
|
@ -635,7 +635,7 @@ sudo journalctl -u sojorn-api --since "1 hour ago" | grep -i error
|
||||||
|
|
||||||
3. **Verify Health**:
|
3. **Verify Health**:
|
||||||
```bash
|
```bash
|
||||||
curl https://api.gosojorn.com/health
|
curl https://api.sojorn.net/health
|
||||||
```
|
```
|
||||||
|
|
||||||
### Database Recovery
|
### Database Recovery
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ Complete guide to deploy Sojorn Flutter Web app to your VPS with Nginx.
|
||||||
|
|
||||||
- VPS with Ubuntu 20.04/22.04 (or Debian-based distro)
|
- VPS with Ubuntu 20.04/22.04 (or Debian-based distro)
|
||||||
- Root or sudo access
|
- Root or sudo access
|
||||||
- Domain name (gosojorn.com) pointed to your VPS IP
|
- Domain name (sojorn.net) pointed to your VPS IP
|
||||||
- SSH access to your VPS
|
- SSH access to your VPS
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -87,7 +87,7 @@ apt install certbot python3-certbot-nginx -y
|
||||||
**Important:** Make sure your domain DNS is already pointing to your VPS IP before running this.
|
**Important:** Make sure your domain DNS is already pointing to your VPS IP before running this.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
certbot --nginx -d gosojorn.com -d www.gosojorn.com
|
certbot --nginx -d sojorn.net -d www.sojorn.net
|
||||||
```
|
```
|
||||||
|
|
||||||
Follow the prompts:
|
Follow the prompts:
|
||||||
|
|
@ -130,21 +130,21 @@ server {
|
||||||
listen 443 ssl http2;
|
listen 443 ssl http2;
|
||||||
listen [::]:443 ssl http2;
|
listen [::]:443 ssl http2;
|
||||||
|
|
||||||
server_name www.gosojorn.com;
|
server_name www.sojorn.net;
|
||||||
|
|
||||||
ssl_certificate /etc/letsencrypt/live/gosojorn.com/fullchain.pem;
|
ssl_certificate /etc/letsencrypt/live/sojorn.net/fullchain.pem;
|
||||||
ssl_certificate_key /etc/letsencrypt/live/gosojorn.com/privkey.pem;
|
ssl_certificate_key /etc/letsencrypt/live/sojorn.net/privkey.pem;
|
||||||
include /etc/letsencrypt/options-ssl-nginx.conf;
|
include /etc/letsencrypt/options-ssl-nginx.conf;
|
||||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||||
|
|
||||||
return 301 https://gosojorn.com$request_uri;
|
return 301 https://sojorn.net$request_uri;
|
||||||
}
|
}
|
||||||
|
|
||||||
# Main server block
|
# Main server block
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
listen [::]:80;
|
listen [::]:80;
|
||||||
server_name gosojorn.com;
|
server_name sojorn.net;
|
||||||
|
|
||||||
# Redirect HTTP to HTTPS
|
# Redirect HTTP to HTTPS
|
||||||
return 301 https://$server_name$request_uri;
|
return 301 https://$server_name$request_uri;
|
||||||
|
|
@ -153,11 +153,11 @@ server {
|
||||||
server {
|
server {
|
||||||
listen 443 ssl http2;
|
listen 443 ssl http2;
|
||||||
listen [::]:443 ssl http2;
|
listen [::]:443 ssl http2;
|
||||||
server_name gosojorn.com;
|
server_name sojorn.net;
|
||||||
|
|
||||||
# SSL Configuration
|
# SSL Configuration
|
||||||
ssl_certificate /etc/letsencrypt/live/gosojorn.com/fullchain.pem;
|
ssl_certificate /etc/letsencrypt/live/sojorn.net/fullchain.pem;
|
||||||
ssl_certificate_key /etc/letsencrypt/live/gosojorn.com/privkey.pem;
|
ssl_certificate_key /etc/letsencrypt/live/sojorn.net/privkey.pem;
|
||||||
include /etc/letsencrypt/options-ssl-nginx.conf;
|
include /etc/letsencrypt/options-ssl-nginx.conf;
|
||||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||||
|
|
||||||
|
|
@ -327,8 +327,8 @@ chmod -R 755 /var/www/sojorn
|
||||||
|
|
||||||
## Part 8: Test Your Deployment
|
## Part 8: Test Your Deployment
|
||||||
|
|
||||||
1. Visit `https://gosojorn.com` - you should see your app
|
1. Visit `https://sojorn.net` - you should see your app
|
||||||
2. Test deep linking: `https://gosojorn.com/username` should route to a profile
|
2. Test deep linking: `https://sojorn.net/username` should route to a profile
|
||||||
3. Check SSL: Look for the padlock icon in the browser
|
3. Check SSL: Look for the padlock icon in the browser
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -508,10 +508,10 @@ certbot renew
|
||||||
You now have:
|
You now have:
|
||||||
✅ Nginx web server installed and configured
|
✅ Nginx web server installed and configured
|
||||||
✅ SSL certificate for HTTPS
|
✅ SSL certificate for HTTPS
|
||||||
✅ Flutter Web app served at https://gosojorn.com
|
✅ Flutter Web app served at https://sojorn.net
|
||||||
✅ Deep linking support for URLs like /username
|
✅ Deep linking support for URLs like /username
|
||||||
✅ Gzip compression for better performance
|
✅ Gzip compression for better performance
|
||||||
✅ Proper security headers
|
✅ Proper security headers
|
||||||
✅ Caching for static assets
|
✅ Caching for static assets
|
||||||
|
|
||||||
Your app is now live and accessible at https://gosojorn.com! 🎉
|
Your app is now live and accessible at https://sojorn.net! 🎉
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@ This document outlines the step-by-step process for cutover from Supabase to the
|
||||||
|
|
||||||
## Phase 4: Cutover (Zero Downtime Strategy)
|
## Phase 4: Cutover (Zero Downtime Strategy)
|
||||||
1. **Parallel Run**: Keep both Supabase and Go VPS running.
|
1. **Parallel Run**: Keep both Supabase and Go VPS running.
|
||||||
2. **DNS Update**: Point your API subdomain (e.g., `api.gosojorn.com`) to the new VPS IP.
|
2. **DNS Update**: Point your API subdomain (e.g., `api.sojorn.net`) to the new VPS IP.
|
||||||
3. **TTL Check**: Ensure DNS TTL is low (e.g., 300s) before starting.
|
3. **TTL Check**: Ensure DNS TTL is low (e.g., 300s) before starting.
|
||||||
4. **Monitor**: Watch logs for 4xx/5xx errors.
|
4. **Monitor**: Watch logs for 4xx/5xx errors.
|
||||||
```bash
|
```bash
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ The infrastructure for GoSojorn is **now fully functional and production-ready**
|
||||||
1. **CORS Resolved:** Fixed "Failed to fetch" errors by implementing dynamic origin matching (required for `AllowCredentials`).
|
1. **CORS Resolved:** Fixed "Failed to fetch" errors by implementing dynamic origin matching (required for `AllowCredentials`).
|
||||||
2. **Schema Complete:** Manually applied missing Signal Protocol migrations (`000002_e2ee_chat.up.sql`).
|
2. **Schema Complete:** Manually applied missing Signal Protocol migrations (`000002_e2ee_chat.up.sql`).
|
||||||
3. **Data Success:** Expanded seeder now provides ~300 posts and ~70 users, satisfying load-test requirements.
|
3. **Data Success:** Expanded seeder now provides ~300 posts and ~70 users, satisfying load-test requirements.
|
||||||
4. **Proxy Verified:** Nginx is correctly routing `api.gosojorn.com` to the Go service.
|
4. **Proxy Verified:** Nginx is correctly routing `api.sojorn.net` to the Go service.
|
||||||
|
|
||||||
## Phase 1: Infrastructure & Environment Integrity
|
## Phase 1: Infrastructure & Environment Integrity
|
||||||
- **Service Health:** ✅
|
- **Service Health:** ✅
|
||||||
|
|
@ -51,4 +51,4 @@ The infrastructure for GoSojorn is **now fully functional and production-ready**
|
||||||
- **Status:** Stress test threshold MET.
|
- **Status:** Stress test threshold MET.
|
||||||
|
|
||||||
## Final Verdict
|
## Final Verdict
|
||||||
The migration from Supabase to GoSojorn is **SUCCESSFUL**. The system is stable, the data is migrated/seeded, and the primary blocker (CORS) is removed. The Supabase instance can be safely paused after final client redirection to `api.gosojorn.com`.
|
The migration from Supabase to GoSojorn is **SUCCESSFUL**. The system is stable, the data is migrated/seeded, and the primary blocker (CORS) is removed. The Supabase instance can be safely paused after final client redirection to `api.sojorn.net`.
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,8 @@ All backend configuration is done. Images should now work properly!
|
||||||
|
|
||||||
### What Was Configured:
|
### What Was Configured:
|
||||||
|
|
||||||
1. ✅ **Custom Domain Connected**: `media.gosojorn.com` → R2 bucket `sojorn-media`
|
1. ✅ **Custom Domain Connected**: `media.sojorn.net` → R2 bucket `sojorn-media`
|
||||||
2. ✅ **Environment Variable Set**: `R2_PUBLIC_URL=https://media.gosojorn.com`
|
2. ✅ **Environment Variable Set**: `R2_PUBLIC_URL=https://media.sojorn.net`
|
||||||
3. ✅ **Edge Function Deployed**: Updated `upload-image` function using custom domain
|
3. ✅ **Edge Function Deployed**: Updated `upload-image` function using custom domain
|
||||||
4. ✅ **DNS Verified**: Domain resolving to Cloudflare CDN
|
4. ✅ **DNS Verified**: Domain resolving to Cloudflare CDN
|
||||||
5. ✅ **API Queries Fixed**: All post queries include `image_url` field
|
5. ✅ **API Queries Fixed**: All post queries include `image_url` field
|
||||||
|
|
@ -28,7 +28,7 @@ In the app:
|
||||||
**Expected behavior**:
|
**Expected behavior**:
|
||||||
- Image uploads successfully
|
- Image uploads successfully
|
||||||
- Post appears in feed with image visible
|
- Post appears in feed with image visible
|
||||||
- Image URL format: `https://media.gosojorn.com/{uuid}.jpg`
|
- Image URL format: `https://media.sojorn.net/{uuid}.jpg`
|
||||||
|
|
||||||
**If it works**: Images will now display everywhere (feed, profiles, chains) ✅
|
**If it works**: Images will now display everywhere (feed, profiles, chains) ✅
|
||||||
|
|
||||||
|
|
@ -43,7 +43,7 @@ ORDER BY created_at DESC
|
||||||
LIMIT 5;
|
LIMIT 5;
|
||||||
```
|
```
|
||||||
|
|
||||||
Expected format: `https://media.gosojorn.com/[uuid].[ext]`
|
Expected format: `https://media.sojorn.net/[uuid].[ext]`
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
|
|
@ -65,7 +65,7 @@ Look for:
|
||||||
|
|
||||||
After uploading an image, copy its URL from the database and test:
|
After uploading an image, copy its URL from the database and test:
|
||||||
```bash
|
```bash
|
||||||
curl -I https://media.gosojorn.com/[filename-from-db]
|
curl -I https://media.sojorn.net/[filename-from-db]
|
||||||
```
|
```
|
||||||
|
|
||||||
Should return `HTTP/1.1 200 OK` or `HTTP/2 200`
|
Should return `HTTP/1.1 200 OK` or `HTTP/2 200`
|
||||||
|
|
@ -129,7 +129,7 @@ Complete guides available:
|
||||||
| API Queries | ✅ Include `image_url` field |
|
| API Queries | ✅ Include `image_url` field |
|
||||||
| Flutter Model | ✅ Post model parses `image_url` |
|
| Flutter Model | ✅ Post model parses `image_url` |
|
||||||
| Widget Display | ✅ PostItem widget shows images |
|
| Widget Display | ✅ PostItem widget shows images |
|
||||||
| Custom Domain | ✅ `media.gosojorn.com` connected |
|
| Custom Domain | ✅ `media.sojorn.net` connected |
|
||||||
|
|
||||||
**Ready to test!** 🚀
|
**Ready to test!** 🚀
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -253,7 +253,7 @@ Available keys: [id, body, created_at, tone_label, allow_chain,
|
||||||
|
|
||||||
**Other feed response (has image_url):**
|
**Other feed response (has image_url):**
|
||||||
```
|
```
|
||||||
DEBUG Post.fromJson: Found image_url in JSON: https://media.gosojorn.com/88a7cc72-...
|
DEBUG Post.fromJson: Found image_url in JSON: https://media.sojorn.net/88a7cc72-...
|
||||||
Available keys: [id, body, author_id, category_id, tone_label, cis_score,
|
Available keys: [id, body, author_id, category_id, tone_label, cis_score,
|
||||||
status, created_at, edited_at, deleted_at, allow_chain,
|
status, created_at, edited_at, deleted_at, allow_chain,
|
||||||
chain_parent_id, image_url, chain_parent, metrics, author]
|
chain_parent_id, image_url, chain_parent, metrics, author]
|
||||||
|
|
@ -271,9 +271,9 @@ I/flutter: PostMedia: post.imageUrl = null
|
||||||
|
|
||||||
### After Fix
|
### After Fix
|
||||||
```
|
```
|
||||||
I/flutter: DEBUG Post.fromJson: Found image_url in JSON: https://media.gosojorn.com/88a7cc72-...
|
I/flutter: DEBUG Post.fromJson: Found image_url in JSON: https://media.sojorn.net/88a7cc72-...
|
||||||
I/flutter: PostMedia: post.imageUrl = https://media.gosojorn.com/88a7cc72-...
|
I/flutter: PostMedia: post.imageUrl = https://media.sojorn.net/88a7cc72-...
|
||||||
I/flutter: PostMedia: SHOWING IMAGE for https://media.gosojorn.com/88a7cc72-...
|
I/flutter: PostMedia: SHOWING IMAGE for https://media.sojorn.net/88a7cc72-...
|
||||||
I/flutter: PostMedia: Image loading... 8899 / 275401
|
I/flutter: PostMedia: Image loading... 8899 / 275401
|
||||||
I/flutter: PostMedia: Image LOADED successfully
|
I/flutter: PostMedia: Image LOADED successfully
|
||||||
```
|
```
|
||||||
|
|
@ -291,7 +291,7 @@ npx supabase functions deploy feed-sojorn feed-personal --no-verify-jwt
|
||||||
### Image Upload Flow (Already Working)
|
### Image Upload Flow (Already Working)
|
||||||
1. User selects image in `ComposeScreen`
|
1. User selects image in `ComposeScreen`
|
||||||
2. Image uploaded via `ImageUploadService.uploadImage()` to Cloudflare R2
|
2. Image uploaded via `ImageUploadService.uploadImage()` to Cloudflare R2
|
||||||
3. Returns public URL: `https://media.gosojorn.com/{uuid}.jpg`
|
3. Returns public URL: `https://media.sojorn.net/{uuid}.jpg`
|
||||||
4. URL sent to `publish-post` edge function
|
4. URL sent to `publish-post` edge function
|
||||||
5. Saved to `posts.image_url` column
|
5. Saved to `posts.image_url` column
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,9 @@
|
||||||
|
|
||||||
- **R2 Bucket**: `sojorn-media`
|
- **R2 Bucket**: `sojorn-media`
|
||||||
- **Account ID**: `7041ca6e0f40307190dc2e65e2fb5e0f`
|
- **Account ID**: `7041ca6e0f40307190dc2e65e2fb5e0f`
|
||||||
- **Custom Domain**: `media.gosojorn.com`
|
- **Custom Domain**: `media.sojorn.net`
|
||||||
- **Upload URL**: `https://7041ca6e0f40307190dc2e65e2fb5e0f.r2.cloudflarestorage.com/sojorn-media`
|
- **Upload URL**: `https://7041ca6e0f40307190dc2e65e2fb5e0f.r2.cloudflarestorage.com/sojorn-media`
|
||||||
- **Public URL**: `https://media.gosojorn.com`
|
- **Public URL**: `https://media.sojorn.net`
|
||||||
|
|
||||||
## Quick Test
|
## Quick Test
|
||||||
|
|
||||||
|
|
@ -15,11 +15,11 @@
|
||||||
Go to: https://dash.cloudflare.com → R2 → `sojorn-media` bucket → Settings
|
Go to: https://dash.cloudflare.com → R2 → `sojorn-media` bucket → Settings
|
||||||
|
|
||||||
Under "Custom Domains", you should see:
|
Under "Custom Domains", you should see:
|
||||||
- ✅ `media.gosojorn.com` with status "Active"
|
- ✅ `media.sojorn.net` with status "Active"
|
||||||
|
|
||||||
If not connected:
|
If not connected:
|
||||||
1. Click "Connect Domain"
|
1. Click "Connect Domain"
|
||||||
2. Enter: `media.gosojorn.com`
|
2. Enter: `media.sojorn.net`
|
||||||
3. Wait 1-2 minutes for activation
|
3. Wait 1-2 minutes for activation
|
||||||
|
|
||||||
### 2. Test Upload in App
|
### 2. Test Upload in App
|
||||||
|
|
@ -47,14 +47,14 @@ ORDER BY created_at DESC
|
||||||
LIMIT 1;
|
LIMIT 1;
|
||||||
```
|
```
|
||||||
|
|
||||||
**Expected URL format**: `https://media.gosojorn.com/[uuid].[ext]`
|
**Expected URL format**: `https://media.sojorn.net/[uuid].[ext]`
|
||||||
|
|
||||||
### 4. Test URL Directly
|
### 4. Test URL Directly
|
||||||
|
|
||||||
Copy the image_url from database and test in browser or curl:
|
Copy the image_url from database and test in browser or curl:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -I https://media.gosojorn.com/[filename-from-database]
|
curl -I https://media.sojorn.net/[filename-from-database]
|
||||||
```
|
```
|
||||||
|
|
||||||
**Expected response**: `HTTP/2 200 OK`
|
**Expected response**: `HTTP/2 200 OK`
|
||||||
|
|
@ -91,7 +91,7 @@ Required secrets:
|
||||||
| Issue | Cause | Solution |
|
| Issue | Cause | Solution |
|
||||||
|-------|-------|----------|
|
|-------|-------|----------|
|
||||||
| Upload fails with 401 | Invalid R2 credentials | Check R2_ACCESS_KEY and R2_SECRET_KEY |
|
| Upload fails with 401 | Invalid R2 credentials | Check R2_ACCESS_KEY and R2_SECRET_KEY |
|
||||||
| Upload succeeds but image 404 | Domain not connected | Connect media.gosojorn.com to bucket |
|
| Upload succeeds but image 404 | Domain not connected | Connect media.sojorn.net to bucket |
|
||||||
| "Missing R2_PUBLIC_URL" | Secret not set/propagated | Wait 2 minutes, redeploy function |
|
| "Missing R2_PUBLIC_URL" | Secret not set/propagated | Wait 2 minutes, redeploy function |
|
||||||
| Image loads slowly | Not cached | Normal for first load, subsequent loads cached |
|
| Image loads slowly | Not cached | Normal for first load, subsequent loads cached |
|
||||||
|
|
||||||
|
|
@ -100,7 +100,7 @@ Required secrets:
|
||||||
✅ **Upload Flow**:
|
✅ **Upload Flow**:
|
||||||
1. User selects image → App processes/filters
|
1. User selects image → App processes/filters
|
||||||
2. App uploads to edge function → Edge function uploads to R2
|
2. App uploads to edge function → Edge function uploads to R2
|
||||||
3. Edge function returns: `https://media.gosojorn.com/[uuid].jpg`
|
3. Edge function returns: `https://media.sojorn.net/[uuid].jpg`
|
||||||
4. App saves post with image_url to database
|
4. App saves post with image_url to database
|
||||||
5. Feed queries posts with image_url
|
5. Feed queries posts with image_url
|
||||||
6. PostItem widget displays image
|
6. PostItem widget displays image
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue