Fix repost state drift and document workflow
This commit is contained in:
@@ -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:<short-sha>`
|
||||||
|
|
||||||
|
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.
|
||||||
@@ -182,7 +182,8 @@ export async function GET(
|
|||||||
const viewerReposts = await db.query.posts.findMany({
|
const viewerReposts = await db.query.posts.findMany({
|
||||||
where: and(
|
where: and(
|
||||||
eq(posts.userId, viewer.id),
|
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));
|
const repostedPostIds = new Set(viewerReposts.map(r => r.repostOfId));
|
||||||
|
|||||||
@@ -821,7 +821,8 @@ export async function GET(request: Request) {
|
|||||||
const viewerReposts = await db.query.posts.findMany({
|
const viewerReposts = await db.query.posts.findMany({
|
||||||
where: and(
|
where: and(
|
||||||
eq(posts.userId, viewer.id),
|
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); });
|
viewerReposts.forEach(r => { if (r.repostOfId) repostedPostIds.add(r.repostOfId); });
|
||||||
|
|||||||
@@ -201,7 +201,8 @@ export async function GET(request: Request) {
|
|||||||
const viewerReposts = await db.query.posts.findMany({
|
const viewerReposts = await db.query.posts.findMany({
|
||||||
where: and(
|
where: and(
|
||||||
eq(posts.userId, viewer.id),
|
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));
|
const repostedPostIds = new Set(viewerReposts.map(r => r.repostOfId));
|
||||||
|
|||||||
@@ -67,7 +67,8 @@ export async function GET(request: Request, context: RouteContext) {
|
|||||||
const viewerReposts = await db.query.posts.findMany({
|
const viewerReposts = await db.query.posts.findMany({
|
||||||
where: and(
|
where: and(
|
||||||
eq(posts.userId, viewer.id),
|
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));
|
const repostedPostIds = new Set(viewerReposts.map(r => r.repostOfId));
|
||||||
|
|||||||
@@ -177,7 +177,8 @@ export async function GET(request: Request, context: RouteContext) {
|
|||||||
const viewerReposts = await db.query.posts.findMany({
|
const viewerReposts = await db.query.posts.findMany({
|
||||||
where: and(
|
where: and(
|
||||||
eq(posts.userId, viewer.id),
|
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));
|
const repostedPostIds = new Set(viewerReposts.map(r => r.repostOfId));
|
||||||
|
|||||||
@@ -274,7 +274,12 @@ export default function ExplorePage() {
|
|||||||
|
|
||||||
const handleRepost = async (postId: string, currentReposted: boolean) => {
|
const handleRepost = async (postId: string, currentReposted: boolean) => {
|
||||||
const method = currentReposted ? 'DELETE' : 'POST';
|
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) => {
|
const handleDelete = (postId: string) => {
|
||||||
|
|||||||
+7
-4
@@ -173,10 +173,13 @@ export default function Home() {
|
|||||||
|
|
||||||
const handleRepost = async (postId: string, currentReposted: boolean) => {
|
const handleRepost = async (postId: string, currentReposted: boolean) => {
|
||||||
if (!did || !handle) return;
|
if (!did || !handle) return;
|
||||||
if (currentReposted) {
|
const res = currentReposted
|
||||||
await signedAPI.unrepostPost(postId, did, handle);
|
? await signedAPI.unrepostPost(postId, did, handle)
|
||||||
} else {
|
: await signedAPI.repostPost(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');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -187,7 +187,12 @@ export default function SearchPage() {
|
|||||||
|
|
||||||
const handleRepost = async (postId: string, currentReposted: boolean) => {
|
const handleRepost = async (postId: string, currentReposted: boolean) => {
|
||||||
const method = currentReposted ? 'DELETE' : 'POST';
|
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) => {
|
const handleDelete = (postId: string) => {
|
||||||
|
|||||||
@@ -213,7 +213,12 @@ export default function ProfilePage() {
|
|||||||
|
|
||||||
const handleRepost = async (postId: string, currentReposted: boolean) => {
|
const handleRepost = async (postId: string, currentReposted: boolean) => {
|
||||||
const method = currentReposted ? 'DELETE' : 'POST';
|
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) => {
|
const handleComment = (post: Post) => {
|
||||||
|
|||||||
@@ -92,10 +92,13 @@ export default function PostDetailPage() {
|
|||||||
|
|
||||||
const handleRepost = async (postId: string, currentReposted: boolean) => {
|
const handleRepost = async (postId: string, currentReposted: boolean) => {
|
||||||
if (!did || !userHandle) return;
|
if (!did || !userHandle) return;
|
||||||
if (currentReposted) {
|
const res = currentReposted
|
||||||
await signedAPI.unrepostPost(postId, did, userHandle);
|
? await signedAPI.unrepostPost(postId, did, userHandle)
|
||||||
} else {
|
: await signedAPI.repostPost(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');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ function LinkPreviewImage({ src, alt }: { src: string; alt: string }) {
|
|||||||
interface PostCardProps {
|
interface PostCardProps {
|
||||||
post: Post;
|
post: Post;
|
||||||
onLike?: (id: string, currentLiked: boolean) => void;
|
onLike?: (id: string, currentLiked: boolean) => void;
|
||||||
onRepost?: (id: string, currentReposted: boolean) => void;
|
onRepost?: (id: string, currentReposted: boolean) => Promise<void> | void;
|
||||||
onComment?: (post: Post) => void;
|
onComment?: (post: Post) => void;
|
||||||
onDelete?: (id: string) => void;
|
onDelete?: (id: string) => void;
|
||||||
onHide?: (id: string) => void; // Called when post should be hidden (block/mute)
|
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 router = useRouter();
|
||||||
const [liked, setLiked] = useState(post.isLiked || false);
|
const [liked, setLiked] = useState(post.isLiked || false);
|
||||||
const [reposted, setReposted] = useState(post.isReposted || 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 [reporting, setReporting] = useState(false);
|
||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
const [showMenu, setShowMenu] = useState(false);
|
const [showMenu, setShowMenu] = useState(false);
|
||||||
@@ -61,7 +63,8 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide,
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLiked(post.isLiked || false);
|
setLiked(post.isLiked || false);
|
||||||
setReposted(post.isReposted || 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 formatTime = (dateStr: string | Date) => {
|
||||||
const date = new Date(dateStr);
|
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
|
onLike?.(post.id, currentLiked); // Pass current state before toggle
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRepost = (e: React.MouseEvent) => {
|
const handleRepost = async (e: React.MouseEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
||||||
|
if (repostPending) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!isIdentityUnlocked) {
|
if (!isIdentityUnlocked) {
|
||||||
showToast('Please log in to repost', 'error');
|
showToast('Please log in to repost', 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentReposted = reposted;
|
const currentReposted = reposted;
|
||||||
setReposted(!currentReposted);
|
const currentRepostsCount = repostsCount;
|
||||||
onRepost?.(post.id, currentReposted); // Pass current state before toggle
|
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) => {
|
const handleComment = (e: React.MouseEvent) => {
|
||||||
@@ -657,9 +679,9 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide,
|
|||||||
<MessageIcon />
|
<MessageIcon />
|
||||||
<span>{post.repliesCount || ''}</span>
|
<span>{post.repliesCount || ''}</span>
|
||||||
</button>
|
</button>
|
||||||
<button className={`post-action ${reposted ? 'reposted' : ''}`} onClick={handleRepost}>
|
<button className={`post-action ${reposted ? 'reposted' : ''}`} onClick={handleRepost} disabled={repostPending}>
|
||||||
<RepeatIcon />
|
<RepeatIcon />
|
||||||
<span>{(post.repostsCount - (post.isReposted ? 1 : 0)) + (reposted ? 1 : 0) || ''}</span>
|
<span>{repostsCount || ''}</span>
|
||||||
</button>
|
</button>
|
||||||
<button className={`post-action ${liked ? 'liked' : ''}`} onClick={handleLike}>
|
<button className={`post-action ${liked ? 'liked' : ''}`} onClick={handleLike}>
|
||||||
<HeartIcon filled={liked} />
|
<HeartIcon filled={liked} />
|
||||||
|
|||||||
Reference in New Issue
Block a user