From 17baf8e4f014ae2c152eb0d197e1e95118f6ea3a Mon Sep 17 00:00:00 2001 From: cyph3rasi Date: Sat, 7 Mar 2026 19:47:49 -0800 Subject: [PATCH] Fix repost state drift and document workflow --- HOW_TO_WORK_ON_SYNAPIS.md | 185 ++++++++++++++++++++++ src/app/api/posts/[id]/route.ts | 3 +- src/app/api/posts/route.ts | 3 +- src/app/api/search/route.ts | 3 +- src/app/api/users/[handle]/likes/route.ts | 3 +- src/app/api/users/[handle]/posts/route.ts | 3 +- src/app/explore/page.tsx | 7 +- src/app/page.tsx | 11 +- src/app/search/page.tsx | 7 +- src/app/u/[handle]/page.tsx | 7 +- src/app/u/[handle]/posts/[id]/page.tsx | 11 +- src/components/PostCard.tsx | 36 ++++- 12 files changed, 256 insertions(+), 23 deletions(-) create mode 100644 HOW_TO_WORK_ON_SYNAPIS.md diff --git a/HOW_TO_WORK_ON_SYNAPIS.md b/HOW_TO_WORK_ON_SYNAPIS.md new file mode 100644 index 0000000..96da2f0 --- /dev/null +++ b/HOW_TO_WORK_ON_SYNAPIS.md @@ -0,0 +1,185 @@ +# How To Work On Synapsis + +This is the practical workflow for developing Synapsis without turning every bugfix into a full Docker release. + +## The Basic Rule + +Use three different loops for three different jobs: + +1. `npm run dev` for normal feature work and bugfixes +2. local Docker source builds when you need container parity +3. GHCR publish only when you actually want the server to update + +Do **not** rebuild and push a Docker image for every tiny fix. Batch fixes together, verify them locally, then publish when the server needs the new version. + +## 1. Normal Local Development + +Use this for most day-to-day work. + +```bash +npm install +cp .env.example .env +npm run db:push +npm run dev +``` + +Useful verification commands: + +```bash +npm run type-check +npm run build +npm test +``` + +Use this loop when: +- changing UI +- fixing API logic +- working on auth, feed logic, posting, bots, or settings +- you do not specifically need to test the Docker runtime + +## 2. Local Docker Parity Test + +Use this when you want to know whether the app still works inside the actual container setup. + +This compose file builds from your local source tree: + +```bash +cd docker +cp .env.example .env +docker compose up --build +``` + +That uses: +- [docker/docker-compose.yml](/Users/christopher/Dev/Synapsis/Synapsis/docker/docker-compose.yml) +- [docker/Dockerfile](/Users/christopher/Dev/Synapsis/Synapsis/docker/Dockerfile) + +Use this loop when: +- Docker-specific startup behavior matters +- you changed the Dockerfile or entrypoint +- you changed env handling, healthchecks, migrations, ports, or install flow + +## 3. Production Image Publish + +Use this only when you want the server or end users to pull a new image. + +The production install uses: +- [docker-compose.yml](/Users/christopher/Dev/Synapsis/Synapsis/docker-compose.yml) +- image: `ghcr.io/gnosyslabs/synapsis:latest` + +### First-time GHCR auth on this machine + +```bash +gh auth refresh -h github.com -s read:packages -s write:packages +gh auth token | docker login ghcr.io -u YOUR_GITHUB_USERNAME --password-stdin +``` + +### Publish the image + +Push code first: + +```bash +git push origin main +``` + +Then build and push the multi-arch image: + +```bash +docker buildx build \ + --builder colima \ + --platform linux/amd64,linux/arm64 \ + -f docker/Dockerfile \ + -t ghcr.io/gnosyslabs/synapsis:latest \ + -t ghcr.io/gnosyslabs/synapsis:$(git rev-parse --short HEAD) \ + --push \ + . +``` + +That publishes: +- `ghcr.io/gnosyslabs/synapsis:latest` +- `ghcr.io/gnosyslabs/synapsis:` + +If you are not on a Mac/Colima setup, swap `--builder colima` for whatever local buildx builder you use. + +## 4. Update The Server + +Once a new image is published, update the server with: + +```bash +cd /opt/synapsis +docker compose pull +docker compose up -d +``` + +Useful checks: + +```bash +docker compose ps +docker compose logs -f app +docker compose images +``` + +## 5. Which Compose File Is Which + +There are two main Docker compose paths in this repo. + +### Local source-build compose + +File: +- [docker/docker-compose.yml](/Users/christopher/Dev/Synapsis/Synapsis/docker/docker-compose.yml) + +Purpose: +- local Docker testing +- builds from your current working tree +- no GHCR push required + +### Production install compose + +File: +- [docker-compose.yml](/Users/christopher/Dev/Synapsis/Synapsis/docker-compose.yml) + +Purpose: +- end-user install +- server deployment +- uses `ghcr.io/gnosyslabs/synapsis:latest` + +Do not confuse them. + +## 6. Recommended Workflow + +This is the default path that makes the most sense for Synapsis: + +1. Make code changes locally +2. Run `npm run type-check` +3. Run `npm run build` +4. If Docker behavior matters, run `cd docker && docker compose up --build` +5. Keep stacking fixes until the server actually needs them +6. Commit and push +7. Build and push the GHCR image +8. Pull and restart on the server + +## 7. When To Publish A New Docker Image + +Publish when: +- you want the fix on the real server +- you changed install/runtime/container behavior +- you finished a coherent batch of fixes + +Do not publish just because: +- one small UI bug was fixed locally +- one small API bug was fixed and not needed on the server yet + +## 8. Current Install Reality + +For clean servers, the normal install path is: + +```bash +curl -fsSL https://synapsis.social/install.sh | bash +``` + +For servers that already run nginx or another reverse proxy on `80/443`, use: + +```bash +curl -fsSL https://synapsis.social/install.sh | PROXY=none bash +``` + +In `PROXY=none` mode, Synapsis binds to `127.0.0.1:${PORT}` and your existing reverse proxy should point there. diff --git a/src/app/api/posts/[id]/route.ts b/src/app/api/posts/[id]/route.ts index 45c0cd2..c7541b4 100644 --- a/src/app/api/posts/[id]/route.ts +++ b/src/app/api/posts/[id]/route.ts @@ -182,7 +182,8 @@ export async function GET( const viewerReposts = await db.query.posts.findMany({ where: and( eq(posts.userId, viewer.id), - inArray(posts.repostOfId, allPostIds) + inArray(posts.repostOfId, allPostIds), + eq(posts.isRemoved, false) ), }); const repostedPostIds = new Set(viewerReposts.map(r => r.repostOfId)); diff --git a/src/app/api/posts/route.ts b/src/app/api/posts/route.ts index f05840c..01fefbb 100644 --- a/src/app/api/posts/route.ts +++ b/src/app/api/posts/route.ts @@ -821,7 +821,8 @@ export async function GET(request: Request) { const viewerReposts = await db.query.posts.findMany({ where: and( eq(posts.userId, viewer.id), - inArray(posts.repostOfId, localPostIds) + inArray(posts.repostOfId, localPostIds), + eq(posts.isRemoved, false) ), }); viewerReposts.forEach(r => { if (r.repostOfId) repostedPostIds.add(r.repostOfId); }); diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts index b1f3414..c3cd440 100644 --- a/src/app/api/search/route.ts +++ b/src/app/api/search/route.ts @@ -201,7 +201,8 @@ export async function GET(request: Request) { const viewerReposts = await db.query.posts.findMany({ where: and( eq(posts.userId, viewer.id), - inArray(posts.repostOfId, postIds) + inArray(posts.repostOfId, postIds), + eq(posts.isRemoved, false) ), }); const repostedPostIds = new Set(viewerReposts.map(r => r.repostOfId)); diff --git a/src/app/api/users/[handle]/likes/route.ts b/src/app/api/users/[handle]/likes/route.ts index c4b8ec9..90cf6e2 100644 --- a/src/app/api/users/[handle]/likes/route.ts +++ b/src/app/api/users/[handle]/likes/route.ts @@ -67,7 +67,8 @@ export async function GET(request: Request, context: RouteContext) { const viewerReposts = await db.query.posts.findMany({ where: and( eq(posts.userId, viewer.id), - inArray(posts.repostOfId, postIds) + inArray(posts.repostOfId, postIds), + eq(posts.isRemoved, false) ), }); const repostedPostIds = new Set(viewerReposts.map(r => r.repostOfId)); diff --git a/src/app/api/users/[handle]/posts/route.ts b/src/app/api/users/[handle]/posts/route.ts index a73a230..f0c5f7f 100644 --- a/src/app/api/users/[handle]/posts/route.ts +++ b/src/app/api/users/[handle]/posts/route.ts @@ -177,7 +177,8 @@ export async function GET(request: Request, context: RouteContext) { const viewerReposts = await db.query.posts.findMany({ where: and( eq(posts.userId, viewer.id), - inArray(posts.repostOfId, postIds) + inArray(posts.repostOfId, postIds), + eq(posts.isRemoved, false) ), }); const repostedPostIds = new Set(viewerReposts.map(r => r.repostOfId)); diff --git a/src/app/explore/page.tsx b/src/app/explore/page.tsx index 329bf3d..2ca9bfc 100644 --- a/src/app/explore/page.tsx +++ b/src/app/explore/page.tsx @@ -274,7 +274,12 @@ export default function ExplorePage() { const handleRepost = async (postId: string, currentReposted: boolean) => { const method = currentReposted ? 'DELETE' : 'POST'; - await fetch(`/api/posts/${postId}/repost`, { method }); + const res = await fetch(`/api/posts/${postId}/repost`, { method }); + + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data.error || 'Failed to update repost'); + } }; const handleDelete = (postId: string) => { diff --git a/src/app/page.tsx b/src/app/page.tsx index 8336e1f..b710054 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -173,10 +173,13 @@ export default function Home() { const handleRepost = async (postId: string, currentReposted: boolean) => { if (!did || !handle) return; - if (currentReposted) { - await signedAPI.unrepostPost(postId, did, handle); - } else { - await signedAPI.repostPost(postId, did, handle); + const res = currentReposted + ? await signedAPI.unrepostPost(postId, did, handle) + : await signedAPI.repostPost(postId, did, handle); + + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data.error || 'Failed to update repost'); } }; diff --git a/src/app/search/page.tsx b/src/app/search/page.tsx index d9e15ac..3c15edb 100644 --- a/src/app/search/page.tsx +++ b/src/app/search/page.tsx @@ -187,7 +187,12 @@ export default function SearchPage() { const handleRepost = async (postId: string, currentReposted: boolean) => { const method = currentReposted ? 'DELETE' : 'POST'; - await fetch(`/api/posts/${postId}/repost`, { method }); + const res = await fetch(`/api/posts/${postId}/repost`, { method }); + + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data.error || 'Failed to update repost'); + } }; const handleDelete = (postId: string) => { diff --git a/src/app/u/[handle]/page.tsx b/src/app/u/[handle]/page.tsx index c0b19a6..c8f24d4 100644 --- a/src/app/u/[handle]/page.tsx +++ b/src/app/u/[handle]/page.tsx @@ -213,7 +213,12 @@ export default function ProfilePage() { const handleRepost = async (postId: string, currentReposted: boolean) => { const method = currentReposted ? 'DELETE' : 'POST'; - await fetch(`/api/posts/${postId}/repost`, { method }); + const res = await fetch(`/api/posts/${postId}/repost`, { method }); + + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data.error || 'Failed to update repost'); + } }; const handleComment = (post: Post) => { diff --git a/src/app/u/[handle]/posts/[id]/page.tsx b/src/app/u/[handle]/posts/[id]/page.tsx index b88a393..cdeacc5 100644 --- a/src/app/u/[handle]/posts/[id]/page.tsx +++ b/src/app/u/[handle]/posts/[id]/page.tsx @@ -92,10 +92,13 @@ export default function PostDetailPage() { const handleRepost = async (postId: string, currentReposted: boolean) => { if (!did || !userHandle) return; - if (currentReposted) { - await signedAPI.unrepostPost(postId, did, userHandle); - } else { - await signedAPI.repostPost(postId, did, userHandle); + const res = currentReposted + ? await signedAPI.unrepostPost(postId, did, userHandle) + : await signedAPI.repostPost(postId, did, userHandle); + + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data.error || 'Failed to update repost'); } }; diff --git a/src/components/PostCard.tsx b/src/components/PostCard.tsx index c6e20ee..a894067 100644 --- a/src/components/PostCard.tsx +++ b/src/components/PostCard.tsx @@ -34,7 +34,7 @@ function LinkPreviewImage({ src, alt }: { src: string; alt: string }) { interface PostCardProps { post: Post; onLike?: (id: string, currentLiked: boolean) => void; - onRepost?: (id: string, currentReposted: boolean) => void; + onRepost?: (id: string, currentReposted: boolean) => Promise | void; onComment?: (post: Post) => void; onDelete?: (id: string) => void; onHide?: (id: string) => void; // Called when post should be hidden (block/mute) @@ -50,6 +50,8 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide, const router = useRouter(); const [liked, setLiked] = useState(post.isLiked || false); const [reposted, setReposted] = useState(post.isReposted || false); + const [repostsCount, setRepostsCount] = useState(post.repostsCount || 0); + const [repostPending, setRepostPending] = useState(false); const [reporting, setReporting] = useState(false); const [deleting, setDeleting] = useState(false); const [showMenu, setShowMenu] = useState(false); @@ -61,7 +63,8 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide, useEffect(() => { setLiked(post.isLiked || false); setReposted(post.isReposted || false); - }, [post.isLiked, post.isReposted, post.id]); + setRepostsCount(post.repostsCount || 0); + }, [post.isLiked, post.isReposted, post.repostsCount, post.id]); const formatTime = (dateStr: string | Date) => { const date = new Date(dateStr); @@ -104,18 +107,37 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide, onLike?.(post.id, currentLiked); // Pass current state before toggle }; - const handleRepost = (e: React.MouseEvent) => { + const handleRepost = async (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); + if (repostPending) { + return; + } + if (!isIdentityUnlocked) { showToast('Please log in to repost', 'error'); return; } const currentReposted = reposted; - setReposted(!currentReposted); - onRepost?.(post.id, currentReposted); // Pass current state before toggle + const currentRepostsCount = repostsCount; + const nextReposted = !currentReposted; + const nextRepostsCount = Math.max(0, currentRepostsCount + (currentReposted ? -1 : 1)); + + setReposted(nextReposted); + setRepostsCount(nextRepostsCount); + setRepostPending(true); + + try { + await onRepost?.(post.id, currentReposted); + } catch (error) { + setReposted(currentReposted); + setRepostsCount(currentRepostsCount); + showToast(error instanceof Error ? error.message : 'Failed to update repost', 'error'); + } finally { + setRepostPending(false); + } }; const handleComment = (e: React.MouseEvent) => { @@ -657,9 +679,9 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide, {post.repliesCount || ''} -