feat: Add user likes tab and local timeline, update branding assets

- Add likes tab to user profiles displaying user's liked posts
- Implement new `/api/users/[handle]/likes` endpoint to fetch user's liked posts
- Add local timeline feed type to show only local node posts without fediverse content
- Update favicon and add new logotext branding asset
- Enhance search results with isLiked and isReposted status for authenticated users
- Populate like and repost information in search posts endpoint
- Update profile page tabs to conditionally show likes tab for non-bot users
- Add loading states and empty state messaging for likes tab
- Remove deprecated guide page
- Improve bot settings page and content generation logic
This commit is contained in:
AskIt
2026-01-25 19:45:34 +01:00
parent 465bd8b60c
commit d36c1c93e5
14 changed files with 509 additions and 289 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

+40 -2
View File
@@ -81,13 +81,15 @@ export default function ProfilePage() {
const [user, setUser] = useState<User | null>(null);
const [posts, setPosts] = useState<Post[]>([]);
const [likedPosts, setLikedPosts] = useState<Post[]>([]);
const [currentUser, setCurrentUser] = useState<{ id: string; handle: string } | null>(null);
const [isFollowing, setIsFollowing] = useState(false);
const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState<'posts' | 'followers' | 'following'>('posts');
const [activeTab, setActiveTab] = useState<'posts' | 'likes' | 'followers' | 'following'>('posts');
const [followers, setFollowers] = useState<UserSummary[]>([]);
const [following, setFollowing] = useState<UserSummary[]>([]);
const [postsLoading, setPostsLoading] = useState(true);
const [likesLoading, setLikesLoading] = useState(false);
const [followersLoading, setFollowersLoading] = useState(false);
const [followingLoading, setFollowingLoading] = useState(false);
const [isEditing, setIsEditing] = useState(false);
@@ -106,6 +108,7 @@ export default function ProfilePage() {
setSaveError(null);
setFollowers([]);
setFollowing([]);
setLikedPosts([]);
// Get current user
fetch('/api/auth/me')
.then(res => res.json())
@@ -197,6 +200,15 @@ export default function ProfilePage() {
.catch(() => setFollowing([]))
.finally(() => setFollowingLoading(false));
}
if (activeTab === 'likes') {
setLikesLoading(true);
fetch(`/api/users/${handle}/likes`)
.then(res => res.json())
.then(data => setLikedPosts(data.posts || []))
.catch(() => setLikedPosts([]))
.finally(() => setLikesLoading(false));
}
}, [activeTab, handle]);
const handleFollow = async () => {
@@ -617,7 +629,10 @@ export default function ProfilePage() {
{/* Tabs */}
<div style={{ display: 'flex', borderTop: '1px solid var(--border)' }}>
{(['posts', 'followers', 'following'] as const).map(tab => (
{(user?.isBot
? ['posts', 'followers', 'following'] as const
: ['posts', 'likes', 'followers', 'following'] as const
).map(tab => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
@@ -663,6 +678,29 @@ export default function ProfilePage() {
)
)}
{activeTab === 'likes' && (
likesLoading ? (
<div style={{ padding: '48px', textAlign: 'center', color: 'var(--foreground-tertiary)' }}>
<p>Loading...</p>
</div>
) : likedPosts.length === 0 ? (
<div style={{ padding: '48px', textAlign: 'center', color: 'var(--foreground-tertiary)' }}>
<p>No liked posts yet</p>
</div>
) : (
likedPosts.map((post, index) => (
<PostCard
key={`${post.id}-${index}`}
post={post}
onLike={handleLike}
onRepost={handleRepost}
onComment={handleComment}
onDelete={handleDelete}
/>
))
)
)}
{activeTab === 'followers' && (
followersLoading ? (
<div style={{ padding: '24px', textAlign: 'center', color: 'var(--foreground-tertiary)' }}>
+16 -1
View File
@@ -223,7 +223,22 @@ export async function GET(request: Request) {
eq(posts.isRemoved, false)
);
if (type === 'public') {
if (type === 'local') {
// Local node posts only - no fediverse content
feedPosts = await db.query.posts.findMany({
where: baseFilter,
with: {
author: true,
bot: true,
media: true,
replyTo: {
with: { author: true },
},
},
orderBy: [desc(posts.createdAt)],
limit,
});
} else if (type === 'public') {
// Public timeline - all local posts + all cached remote posts
const localPosts = await db.query.posts.findMany({
where: baseFilter,
+40 -2
View File
@@ -1,6 +1,6 @@
import { NextResponse } from 'next/server';
import { db, users, posts } from '@/db';
import { ilike, or, desc, and, notInArray, eq } from 'drizzle-orm';
import { db, users, posts, likes } from '@/db';
import { ilike, or, desc, and, notInArray, eq, inArray } from 'drizzle-orm';
import { resolveRemoteUser } from '@/lib/activitypub/fetch';
type SearchUser = {
@@ -150,11 +150,49 @@ export async function GET(request: Request) {
with: {
author: true,
media: true,
bot: true,
},
orderBy: [desc(posts.createdAt)],
limit,
});
searchPosts = postResults;
// Populate isLiked and isReposted for authenticated users
try {
const { getSession } = await import('@/lib/auth');
const session = await getSession();
if (session?.user && searchPosts.length > 0) {
const viewer = session.user;
const postIds = searchPosts.map(p => p.id).filter(Boolean);
if (postIds.length > 0) {
const viewerLikes = await db.query.likes.findMany({
where: and(
eq(likes.userId, viewer.id),
inArray(likes.postId, postIds)
),
});
const likedPostIds = new Set(viewerLikes.map(l => l.postId));
const viewerReposts = await db.query.posts.findMany({
where: and(
eq(posts.userId, viewer.id),
inArray(posts.repostOfId, postIds)
),
});
const repostedPostIds = new Set(viewerReposts.map(r => r.repostOfId));
searchPosts = searchPosts.map(p => ({
...p,
isLiked: likedPostIds.has(p.id),
isReposted: repostedPostIds.has(p.id),
})) as any;
}
}
} catch (error) {
console.error('Error populating interaction flags:', error);
}
}
return NextResponse.json({
+94
View File
@@ -0,0 +1,94 @@
import { NextResponse } from 'next/server';
import { db, likes, posts, users } from '@/db';
import { eq, desc, and, inArray } from 'drizzle-orm';
type RouteContext = { params: Promise<{ handle: string }> };
export async function GET(request: Request, context: RouteContext) {
try {
const { handle } = await context.params;
const cleanHandle = handle.toLowerCase().replace(/^@/, '');
const { searchParams } = new URL(request.url);
const limit = Math.min(parseInt(searchParams.get('limit') || '25'), 50);
// Find the user
const user = await db.query.users.findFirst({
where: eq(users.handle, cleanHandle),
});
if (!user) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
// Don't show likes for bot accounts
if (user.isBot) {
return NextResponse.json({ posts: [] });
}
// Get user's liked posts
const userLikes = await db.query.likes.findMany({
where: eq(likes.userId, user.id),
with: {
post: {
with: {
author: true,
media: true,
bot: true,
},
},
},
orderBy: [desc(likes.createdAt)],
limit,
});
// Filter out any likes where the post was removed and format response
let likedPosts = userLikes
.filter(like => like.post && !like.post.isRemoved)
.map(like => like.post);
// Populate isLiked and isReposted for authenticated users
try {
const { getSession } = await import('@/lib/auth');
const session = await getSession();
if (session?.user && likedPosts.length > 0) {
const viewer = session.user;
const postIds = likedPosts.map(p => p!.id).filter(Boolean);
if (postIds.length > 0) {
const viewerLikes = await db.query.likes.findMany({
where: and(
eq(likes.userId, viewer.id),
inArray(likes.postId, postIds)
),
});
const likedPostIds = new Set(viewerLikes.map(l => l.postId));
const viewerReposts = await db.query.posts.findMany({
where: and(
eq(posts.userId, viewer.id),
inArray(posts.repostOfId, postIds)
),
});
const repostedPostIds = new Set(viewerReposts.map(r => r.repostOfId));
likedPosts = likedPosts.map(p => ({
...p!,
isLiked: likedPostIds.has(p!.id),
isReposted: repostedPostIds.has(p!.id),
})) as any;
}
}
} catch (error) {
console.error('Error populating interaction flags:', error);
}
return NextResponse.json({
posts: likedPosts,
nextCursor: likedPosts.length === limit ? likedPosts[likedPosts.length - 1]?.id : null,
});
} catch (error) {
console.error('Get user likes error:', error);
return NextResponse.json({ error: 'Failed to get likes' }, { status: 500 });
}
}
+41 -3
View File
@@ -1,6 +1,6 @@
import { NextResponse } from 'next/server';
import { db, posts, users } from '@/db';
import { eq, desc, and } from 'drizzle-orm';
import { db, posts, users, likes } from '@/db';
import { eq, desc, and, inArray } from 'drizzle-orm';
import { resolveRemoteUser } from '@/lib/activitypub/fetch';
type RouteContext = { params: Promise<{ handle: string }> };
@@ -285,11 +285,12 @@ export async function GET(request: Request, context: RouteContext) {
}
// Get user's posts
const userPosts = await db.query.posts.findMany({
let userPosts: any[] = await db.query.posts.findMany({
where: and(eq(posts.userId, user.id), eq(posts.isRemoved, false)),
with: {
author: true,
media: true,
bot: true,
replyTo: {
with: { author: true },
},
@@ -298,6 +299,43 @@ export async function GET(request: Request, context: RouteContext) {
limit,
});
// Populate isLiked and isReposted for authenticated users
try {
const { getSession } = await import('@/lib/auth');
const session = await getSession();
if (session?.user && userPosts.length > 0) {
const viewer = session.user;
const postIds = userPosts.map(p => p.id).filter(Boolean);
if (postIds.length > 0) {
const viewerLikes = await db.query.likes.findMany({
where: and(
eq(likes.userId, viewer.id),
inArray(likes.postId, postIds)
),
});
const likedPostIds = new Set(viewerLikes.map(l => l.postId));
const viewerReposts = await db.query.posts.findMany({
where: and(
eq(posts.userId, viewer.id),
inArray(posts.repostOfId, postIds)
),
});
const repostedPostIds = new Set(viewerReposts.map(r => r.repostOfId));
userPosts = userPosts.map(p => ({
...p,
isLiked: likedPostIds.has(p.id),
isReposted: repostedPostIds.has(p.id),
}));
}
}
} catch (error) {
console.error('Error populating interaction flags:', error);
}
return NextResponse.json({
posts: userPosts,
nextCursor: userPosts.length === limit ? userPosts[userPosts.length - 1]?.id : null,
+73 -20
View File
@@ -6,7 +6,7 @@ import { SearchIcon, TrendingIcon, UsersIcon } from '@/components/Icons';
import { PostCard } from '@/components/PostCard';
import { Post } from '@/lib/types';
import { formatFullHandle } from '@/lib/utils/handle';
import { Bot } from 'lucide-react';
import { Bot, Globe, Server } from 'lucide-react';
interface User {
id: string;
@@ -60,31 +60,51 @@ function UserCard({ user }: { user: User }) {
export default function ExplorePage() {
const [query, setQuery] = useState('');
const [activeTab, setActiveTab] = useState<'trending' | 'users' | 'search'>('trending');
const [trendingPosts, setTrendingPosts] = useState<Post[]>([]);
const [activeTab, setActiveTab] = useState<'node' | 'fediverse' | 'users' | 'search'>('node');
const [nodePosts, setNodePosts] = useState<Post[]>([]);
const [fediversePosts, setFediversePosts] = useState<Post[]>([]);
const [users, setUsers] = useState<User[]>([]);
const [searchResults, setSearchResults] = useState<{ posts: Post[]; users: User[] }>({ posts: [], users: [] });
const [loading, setLoading] = useState(true);
const [searching, setSearching] = useState(false);
useEffect(() => {
// Load trending posts
const loadTrending = async () => {
// Load node posts (local only)
const loadNodePosts = async () => {
setLoading(true);
try {
const res = await fetch('/api/posts?type=curated&limit=20');
const res = await fetch('/api/posts?type=local&limit=20');
const data = await res.json();
setTrendingPosts(data.posts || []);
setNodePosts(data.posts || []);
} catch {
setTrendingPosts([]);
setNodePosts([]);
} finally {
setLoading(false);
}
};
loadTrending();
loadNodePosts();
}, []);
useEffect(() => {
// Load fediverse posts when tab changes
if (activeTab === 'fediverse' && fediversePosts.length === 0) {
const loadFediverse = async () => {
setLoading(true);
try {
const res = await fetch('/api/posts?type=curated&limit=20');
const data = await res.json();
setFediversePosts(data.posts || []);
} catch {
setFediversePosts([]);
} finally {
setLoading(false);
}
};
loadFediverse();
}
}, [activeTab, fediversePosts.length]);
useEffect(() => {
// Load users when tab changes to users
if (activeTab === 'users' && users.length === 0) {
@@ -136,7 +156,8 @@ export default function ExplorePage() {
};
const handleDelete = (postId: string) => {
setTrendingPosts(prev => prev.filter(p => p.id !== postId));
setNodePosts(prev => prev.filter(p => p.id !== postId));
setFediversePosts(prev => prev.filter(p => p.id !== postId));
setSearchResults(prev => ({
...prev,
posts: prev.posts.filter(p => p.id !== postId)
@@ -160,11 +181,18 @@ export default function ExplorePage() {
<div className="explore-tabs">
<button
className={`explore-tab ${activeTab === 'trending' ? 'active' : ''}`}
onClick={() => setActiveTab('trending')}
className={`explore-tab ${activeTab === 'node' ? 'active' : ''}`}
onClick={() => setActiveTab('node')}
>
<TrendingIcon />
<span>Trending</span>
<Server size={18} />
<span>Node</span>
</button>
<button
className={`explore-tab ${activeTab === 'fediverse' ? 'active' : ''}`}
onClick={() => setActiveTab('fediverse')}
>
<Globe size={18} />
<span>Fediverse</span>
</button>
<button
className={`explore-tab ${activeTab === 'users' ? 'active' : ''}`}
@@ -185,13 +213,38 @@ export default function ExplorePage() {
</div>
<div className="explore-content">
{activeTab === 'trending' && (
{activeTab === 'node' && (
loading ? (
<div className="explore-loading">Loading trending posts...</div>
) : trendingPosts.length === 0 ? (
<div className="explore-loading">Loading posts...</div>
) : nodePosts.length === 0 ? (
<div className="explore-empty">
<TrendingIcon />
<p>No trending posts yet</p>
<Server size={24} />
<p>No posts on this node yet</p>
</div>
) : (
<>
<div className="feed-meta card">
<div className="feed-meta-title">Node feed</div>
<div className="feed-meta-body">
A chronological feed of all posts from users on this node. See what the local community is sharing.
</div>
</div>
<div className="explore-posts">
{nodePosts.map((post) => (
<PostCard key={post.id} post={post} onLike={handleLike} onRepost={handleRepost} onDelete={handleDelete} />
))}
</div>
</>
)
)}
{activeTab === 'fediverse' && (
loading ? (
<div className="explore-loading">Loading fediverse posts...</div>
) : fediversePosts.length === 0 ? (
<div className="explore-empty">
<Globe size={24} />
<p>No fediverse posts yet</p>
</div>
) : (
<>
@@ -202,7 +255,7 @@ export default function ExplorePage() {
</div>
</div>
<div className="explore-posts">
{trendingPosts.map((post) => (
{fediversePosts.map((post) => (
<PostCard key={post.id} post={post} onLike={handleLike} onRepost={handleRepost} onDelete={handleDelete} />
))}
</div>
-189
View File
@@ -1,189 +0,0 @@
'use client';
import Link from 'next/link';
import { ArrowLeftIcon } from '@/components/Icons';
import { Rocket } from 'lucide-react';
export default function GuidePage() {
return (
<div style={{ maxWidth: '700px', margin: '0 auto', padding: '24px 16px 64px' }}>
<header style={{
display: 'flex',
alignItems: 'center',
gap: '16px',
marginBottom: '32px',
}}>
<Link href="/" style={{ color: 'var(--foreground)' }}>
<ArrowLeftIcon />
</Link>
<div>
<h1 style={{ fontSize: '24px', fontWeight: 700 }}>Synapsis Guide</h1>
<p style={{ color: 'var(--foreground-tertiary)', fontSize: '14px' }}>
Understanding the federated social network
</p>
</div>
</header>
{/* Table of Contents */}
<nav className="card" style={{ marginBottom: '32px', padding: '16px' }}>
<div style={{ fontWeight: 600, marginBottom: '12px' }}>Contents</div>
<ul style={{ listStyle: 'none', display: 'flex', flexDirection: 'column', gap: '8px' }}>
<li><a href="#what-is-fediverse" style={{ color: 'var(--foreground-secondary)' }}>1. What is the Fediverse?</a></li>
<li><a href="#how-synapsis-is-different" style={{ color: 'var(--foreground-secondary)' }}>2. How Synapsis is Different</a></li>
<li><a href="#following-remote-users" style={{ color: 'var(--foreground-secondary)' }}>3. Following Users on Other Servers</a></li>
<li><a href="#how-others-follow-you" style={{ color: 'var(--foreground-secondary)' }}>4. How Others Follow You</a></li>
<li><a href="#portable-identity" style={{ color: 'var(--foreground-secondary)' }}>5. Portable Identity & Account Migration</a></li>
</ul>
</nav>
{/* Section 1 */}
<section id="what-is-fediverse" style={{ marginBottom: '40px' }}>
<h2 style={{ fontSize: '20px', fontWeight: 600, marginBottom: '16px', paddingTop: '8px', borderTop: '1px solid var(--border)' }}>
1. What is the Fediverse?
</h2>
<p style={{ color: 'var(--foreground-secondary)', lineHeight: 1.7, marginBottom: '16px' }}>
The <strong style={{ color: 'var(--foreground)' }}>Fediverse</strong> (federated universe) is a network of interconnected social platforms that can talk to each other. Unlike centralized platforms like Twitter or Facebook, the Fediverse is made up of thousands of independent servers (called "instances" or "nodes") that share a common protocol.
</p>
<p style={{ color: 'var(--foreground-secondary)', lineHeight: 1.7, marginBottom: '16px' }}>
Think of it like email: you can send an email from Gmail to Outlook because they speak the same language. Similarly, you can follow someone on a Mastodon server from your Synapsis account because they both speak <strong style={{ color: 'var(--foreground)' }}>ActivityPub</strong>.
</p>
<div className="card" style={{ background: 'var(--background-tertiary)', padding: '16px' }}>
<div style={{ fontSize: '13px', color: 'var(--foreground-tertiary)', marginBottom: '8px' }}>Key Terms</div>
<ul style={{ listStyle: 'disc', paddingLeft: '20px', color: 'var(--foreground-secondary)', lineHeight: 1.7 }}>
<li><strong style={{ color: 'var(--foreground)' }}>Node / Instance:</strong> An independent server running social software (like this Synapsis node).</li>
<li><strong style={{ color: 'var(--foreground)' }}>ActivityPub:</strong> The protocol that allows different servers to communicate.</li>
<li><strong style={{ color: 'var(--foreground)' }}>Handle:</strong> Your username, including your server (e.g., <code>@alice@mynode.com</code>).</li>
</ul>
</div>
</section>
{/* Section 2 */}
<section id="how-synapsis-is-different" style={{ marginBottom: '40px' }}>
<h2 style={{ fontSize: '20px', fontWeight: 600, marginBottom: '16px', paddingTop: '8px', borderTop: '1px solid var(--border)' }}>
2. How Synapsis is Different
</h2>
<p style={{ color: 'var(--foreground-secondary)', lineHeight: 1.7, marginBottom: '16px' }}>
While Synapsis uses ActivityPub like Mastodon, it introduces a key difference: <strong style={{ color: 'var(--foreground)' }}>Decentralized Identifiers (DIDs)</strong>.
</p>
<h3 style={{ fontSize: '16px', fontWeight: 600, marginBottom: '12px', color: 'var(--foreground)' }}>
The Problem with Traditional Federation
</h3>
<p style={{ color: 'var(--foreground-secondary)', lineHeight: 1.7, marginBottom: '16px' }}>
On Mastodon, your identity is <code>@username@server.com</code>. If your server shuts down, or you want to move to a different one, you effectively lose your identity. Your followers have to re-follow your new account, and your post history doesn't come with you.
</p>
<h3 style={{ fontSize: '16px', fontWeight: 600, marginBottom: '12px', color: 'var(--foreground)' }}>
The Synapsis Approach: DIDs
</h3>
<p style={{ color: 'var(--foreground-secondary)', lineHeight: 1.7, marginBottom: '16px' }}>
When you create an account on Synapsis, you're assigned a unique <strong style={{ color: 'var(--foreground)' }}>DID (Decentralized Identifier)</strong> a cryptographic ID like <code>did:key:z6Mk...</code>. This DID is your true identity. Your human-readable handle (<code>@alice</code>) is simply a friendly pointer to that DID.
</p>
<div className="card" style={{ background: 'var(--background-tertiary)', padding: '16px', marginBottom: '16px' }}>
<div style={{ fontSize: '13px', color: 'var(--foreground-tertiary)', marginBottom: '8px' }}>What this means for you</div>
<ul style={{ listStyle: 'disc', paddingLeft: '20px', color: 'var(--foreground-secondary)', lineHeight: 1.7 }}>
<li><strong style={{ color: 'var(--foreground)' }}>You own your identity.</strong> Your DID is generated from a cryptographic key pair that only you control.</li>
<li><strong style={{ color: 'var(--foreground)' }}>Authenticity.</strong> Every post you make is cryptographically signed, proving it came from you.</li>
</ul>
</div>
</section>
{/* Section 3 */}
<section id="following-remote-users" style={{ marginBottom: '40px' }}>
<h2 style={{ fontSize: '20px', fontWeight: 600, marginBottom: '16px', paddingTop: '8px', borderTop: '1px solid var(--border)' }}>
3. Following Users on Other Servers
</h2>
<h3 style={{ fontSize: '16px', fontWeight: 600, marginBottom: '12px', color: 'var(--foreground)' }}>
Following Someone on Another Synapsis Node
</h3>
<p style={{ color: 'var(--foreground-secondary)', lineHeight: 1.7, marginBottom: '16px' }}>
To follow a user on a different Synapsis node, use the <Link href="/explore" style={{ color: 'var(--accent)' }}>Explore / Search</Link> feature. Enter their full handle in the format:
</p>
<div className="card" style={{ background: 'var(--background)', padding: '12px 16px', fontFamily: 'monospace', marginBottom: '16px' }}>
@username@other-node.com
</div>
<p style={{ color: 'var(--foreground-secondary)', lineHeight: 1.7, marginBottom: '16px' }}>
Synapsis uses <strong style={{ color: 'var(--foreground)' }}>WebFinger</strong> to discover the user's profile on the remote server, then sends a Follow request via ActivityPub.
</p>
<h3 style={{ fontSize: '16px', fontWeight: 600, marginBottom: '12px', color: 'var(--foreground)' }}>
Following Someone on Mastodon (or other Fediverse platforms)
</h3>
<p style={{ color: 'var(--foreground-secondary)', lineHeight: 1.7, marginBottom: '16px' }}>
The process is identical! Enter their full handle:
</p>
<div className="card" style={{ background: 'var(--background)', padding: '12px 16px', fontFamily: 'monospace', marginBottom: '16px' }}>
@user@mastodon.social
</div>
<p style={{ color: 'var(--foreground-secondary)', lineHeight: 1.7 }}>
Because ActivityPub is an open standard, Synapsis can communicate with Mastodon, Pleroma, Misskey, and other compatible platforms.
</p>
</section>
{/* Section 4 */}
<section id="how-others-follow-you" style={{ marginBottom: '40px' }}>
<h2 style={{ fontSize: '20px', fontWeight: 600, marginBottom: '16px', paddingTop: '8px', borderTop: '1px solid var(--border)' }}>
4. How Others Follow You
</h2>
<p style={{ color: 'var(--foreground-secondary)', lineHeight: 1.7, marginBottom: '16px' }}>
To let someone on another server follow you, share your <strong style={{ color: 'var(--foreground)' }}>full handle</strong>. It includes your username and this node's domain:
</p>
<div className="card" style={{ background: 'var(--background)', padding: '12px 16px', fontFamily: 'monospace', marginBottom: '16px' }}>
@your-username@{typeof window !== 'undefined' ? window.location.host : 'this-node.com'}
</div>
<p style={{ color: 'var(--foreground-secondary)', lineHeight: 1.7, marginBottom: '16px' }}>
When they search for your handle on their platform (Mastodon, another Synapsis node, etc.), their server will use WebFinger to find your profile and send a Follow request.
</p>
<p style={{ color: 'var(--foreground-secondary)', lineHeight: 1.7 }}>
You can find your full handle on your <Link href="/" style={{ color: 'var(--accent)' }}>Profile page</Link>.
</p>
</section>
{/* Section 5 */}
<section id="portable-identity" style={{ marginBottom: '40px' }}>
<h2 style={{ fontSize: '20px', fontWeight: 600, marginBottom: '16px', paddingTop: '8px', borderTop: '1px solid var(--border)' }}>
5. Portable Identity & Account Migration
</h2>
<p style={{ color: 'var(--foreground-secondary)', lineHeight: 1.7, marginBottom: '16px' }}>
Synapsis offers true <strong style={{ color: 'var(--foreground)' }}>account portability</strong> powered by DIDs. You can migrate your entire account identity, posts, and media to another Synapsis node.
</p>
<h3 style={{ fontSize: '16px', fontWeight: 600, marginBottom: '12px', color: 'var(--foreground)' }}>
What Gets Migrated
</h3>
<ul style={{ listStyle: 'disc', paddingLeft: '20px', color: 'var(--foreground-secondary)', lineHeight: 1.7, marginBottom: '16px' }}>
<li><strong style={{ color: 'var(--foreground)' }}>Your DID & Keys:</strong> Your cryptographic identity stays exactly the same</li>
<li><strong style={{ color: 'var(--foreground)' }}>Posts & Media:</strong> Your entire post history and uploaded media</li>
<li><strong style={{ color: 'var(--foreground)' }}>Following List:</strong> Who you follow</li>
<li><strong style={{ color: 'var(--foreground)' }}>Synapsis Followers:</strong> Automatically migrated (they recognize your DID)</li>
<li><strong style={{ color: 'var(--foreground)' }}>Fediverse Followers:</strong> Notified of your move (they can re-follow)</li>
</ul>
<h3 style={{ fontSize: '16px', fontWeight: 600, marginBottom: '12px', color: 'var(--foreground)' }}>
How to Migrate
</h3>
<ol style={{ paddingLeft: '20px', color: 'var(--foreground-secondary)', lineHeight: 1.7, marginBottom: '16px' }}>
<li style={{ marginBottom: '8px' }}>Go to <Link href="/settings/migration" style={{ color: 'var(--accent)' }}>Settings Account Migration</Link></li>
<li style={{ marginBottom: '8px' }}>Click <strong>Export Account</strong> and enter your password</li>
<li style={{ marginBottom: '8px' }}>Download your export file (keep it safe!)</li>
<li style={{ marginBottom: '8px' }}>On the new node, go to Settings Account Migration Import</li>
<li style={{ marginBottom: '8px' }}>Upload your export file and choose a handle</li>
</ol>
<div className="card" style={{ background: 'var(--accent-muted)', padding: '16px', borderLeft: '3px solid var(--accent)' }}>
<div style={{ fontWeight: 600, marginBottom: '8px', display: 'flex', alignItems: 'center', gap: '8px' }}>
<Rocket size={18} /> The Synapsis Advantage
</div>
<p style={{ color: 'var(--foreground-secondary)', lineHeight: 1.7, margin: 0 }}>
Unlike Mastodon where followers must manually re-follow you, <strong>Synapsis followers are automatically migrated</strong> because they follow your DID, not just a server-specific account. This is true account portability.
</p>
</div>
</section>
<footer style={{ paddingTop: '24px', borderTop: '1px solid var(--border)', color: 'var(--foreground-tertiary)', fontSize: '13px' }}>
<p>Have questions? Reach out to the node administrator or contribute to Synapsis on GitHub.</p>
</footer>
</div>
);
}
+57 -33
View File
@@ -19,6 +19,7 @@ interface BotData {
name: string;
handle: string;
bio: string;
avatarUrl: string | null;
isActive: boolean;
isSuspended: boolean;
autonomousMode: boolean;
@@ -108,42 +109,65 @@ export default function BotsPage() {
onClick={() => router.push(`/settings/bots/${bot.id}`)}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start', marginBottom: '12px' }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '4px' }}>
<h2 style={{ fontSize: '16px', fontWeight: 600 }}>{bot.name}</h2>
{bot.autonomousMode && (
<span style={{
display: 'inline-flex',
alignItems: 'center',
gap: '4px',
fontSize: '11px',
padding: '3px 8px',
borderRadius: 'var(--radius-full)',
background: 'var(--accent-muted)',
color: 'var(--accent)',
<div style={{ display: 'flex', gap: '12px', flex: 1, minWidth: 0 }}>
<Link
href={`/${bot.handle}`}
onClick={(e) => e.stopPropagation()}
className="avatar"
style={{
width: '48px',
height: '48px',
flexShrink: 0,
fontSize: '18px',
}}
>
{bot.avatarUrl ? (
<img src={bot.avatarUrl} alt={bot.name} style={{ width: '100%', height: '100%', objectFit: 'cover', borderRadius: '50%' }} />
) : (
bot.name.charAt(0).toUpperCase()
)}
</Link>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '4px' }}>
<h2 style={{ fontSize: '16px', fontWeight: 600 }}>{bot.name}</h2>
{bot.autonomousMode && (
<span style={{
display: 'inline-flex',
alignItems: 'center',
gap: '4px',
fontSize: '11px',
padding: '3px 8px',
borderRadius: 'var(--radius-full)',
background: 'var(--accent-muted)',
color: 'var(--accent)',
}}>
<Sparkles size={12} />
Auto
</span>
)}
</div>
<Link
href={`/${bot.handle}`}
onClick={(e) => e.stopPropagation()}
style={{ fontSize: '13px', color: 'var(--foreground-tertiary)' }}
>
@{bot.handle}
</Link>
{bot.bio && (
<p style={{
fontSize: '13px',
color: 'var(--foreground-secondary)',
marginTop: '8px',
overflow: 'hidden',
textOverflow: 'ellipsis',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
}}>
<Sparkles size={12} />
Auto
</span>
{bot.bio}
</p>
)}
</div>
<p style={{ fontSize: '13px', color: 'var(--foreground-tertiary)' }}>
@{bot.handle}
</p>
{bot.bio && (
<p style={{
fontSize: '13px',
color: 'var(--foreground-secondary)',
marginTop: '8px',
overflow: 'hidden',
textOverflow: 'ellipsis',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
}}>
{bot.bio}
</p>
)}
</div>
<div style={{ display: 'flex', gap: '6px', flexShrink: 0 }}>
{bot.isSuspended ? (
+15 -3
View File
@@ -48,15 +48,27 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, isDetail
setReposted(post.isReposted || false);
}, [post.isLiked, post.isReposted, post.id]);
const formatTime = (dateStr: string) => {
const formatTime = (dateStr: string | Date) => {
const date = new Date(dateStr);
if (isNaN(date.getTime())) {
return '';
}
const now = new Date();
const diff = now.getTime() - date.getTime();
const minutes = Math.floor(diff / 60000);
// If post is in the future (minor clock skew), show "now"
if (diff < 0) {
return 'now';
}
const seconds = Math.floor(diff / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (minutes < 1) return 'now';
if (seconds < 60) return 'now';
if (minutes < 60) return `${minutes}m`;
if (hours < 24) return `${hours}h`;
if (days < 7) return `${days}d`;
+3 -7
View File
@@ -1,9 +1,10 @@
'use client';
import Link from 'next/link';
import Image from 'next/image';
import { usePathname } from 'next/navigation';
import { useAuth } from '@/lib/contexts/AuthContext';
import { HomeIcon, SearchIcon, BellIcon, UserIcon, ShieldIcon, SynapsisLogo, BookOpenIcon, SettingsIcon, BotIcon } from './Icons';
import { HomeIcon, SearchIcon, BellIcon, UserIcon, ShieldIcon, SettingsIcon, BotIcon } from './Icons';
import { formatFullHandle } from '@/lib/utils/handle';
export function Sidebar() {
@@ -16,8 +17,7 @@ export function Sidebar() {
return (
<aside className="sidebar">
<Link href="/" className="logo">
<SynapsisLogo />
<span>Synapsis</span>
<Image src="/logotext.png" alt="Synapsis" width={185} height={42} priority />
</Link>
<nav>
<Link href="/" className={`nav-item ${isHome ? 'active' : ''}`}>
@@ -38,10 +38,6 @@ export function Sidebar() {
<span>Bots</span>
</Link>
)}
<Link href="/guide" className={`nav-item ${pathname?.startsWith('/guide') ? 'active' : ''}`}>
<BookOpenIcon />
<span>Guide</span>
</Link>
{user ? (
<Link href={`/${user.handle}`} className={`nav-item ${pathname === '/' + user.handle ? 'active' : ''}`}>
<UserIcon />
+61 -9
View File
@@ -108,6 +108,11 @@ export const MAX_SOURCE_CONTENT_LENGTH = 4000;
*/
export const MAX_CONVERSATION_CONTEXT_LENGTH = 2000;
/**
* Maximum character length for previous posts context.
*/
export const MAX_PREVIOUS_POSTS_CONTEXT_LENGTH = 8000;
/**
* Truncation suffix added to truncated content.
*/
@@ -212,18 +217,21 @@ export function isContentTruncated(content: string): boolean {
* Includes personality context and instructions.
*
* @param personality - Bot's personality configuration
* @param hasSourceContent - Whether the post has source content with a URL
* @returns System prompt string
*
* Validates: Requirements 3.2, 11.1
*/
export function buildPostSystemPrompt(personality: PersonalityConfig): string {
export function buildPostSystemPrompt(personality: PersonalityConfig, hasSourceContent: boolean = true): string {
let prompt = personality.systemPrompt;
if (personality.responseStyle) {
prompt += `\n\nResponse Style: ${personality.responseStyle}`;
}
prompt += `\n\nIMPORTANT: Your posts MUST be under 450 characters (not including the URL). This leaves room for the source link. This is a strict limit.
if (hasSourceContent) {
// Instructions for posts with source content (include URL)
prompt += `\n\nIMPORTANT: Your posts MUST be under 450 characters (not including the URL). This leaves room for the source link. This is a strict limit.
Instructions for creating posts:
- Create engaging, original content based on the source material
@@ -233,7 +241,22 @@ Instructions for creating posts:
- Do not simply copy or summarize - add value with your unique voice
- Do NOT use hashtags
- Write like a human, not a marketing bot
- AVOID repeating themes, phrases, or sentence structures from your previous posts
- Format: [Your commentary] [URL]`;
} else {
// Instructions for original posts without source content (no URL needed)
prompt += `\n\nIMPORTANT: Your posts MUST be under 500 characters. This is a strict limit.
Instructions for creating posts:
- Create engaging, original content that fits your personality
- Keep the text concise - aim for 100-400 characters
- Do NOT include any URLs or links
- Do NOT use hashtags
- Write like a human, not a marketing bot
- Be creative and stay in character
- AVOID repeating themes, phrases, or sentence structures from your previous posts
- Each post should feel fresh and different while maintaining your voice`;
}
return prompt;
}
@@ -294,22 +317,48 @@ Respond with a JSON object containing:
*
* @param sourceContent - Source content to post about
* @param context - Optional additional context
* @param previousPosts - Optional array of previous post contents for context
* @returns User message string
*/
export function buildPostUserMessage(
sourceContent?: ContentItem,
context?: string
context?: string,
previousPosts?: string[]
): string {
let message = '';
// Add previous posts context if available
if (previousPosts && previousPosts.length > 0) {
message += 'Your recent posts (for context - avoid repeating similar content):\n';
// Truncate previous posts if too long
let contextLength = 0;
const relevantPosts: string[] = [];
for (const post of previousPosts) {
if (contextLength + post.length > MAX_PREVIOUS_POSTS_CONTEXT_LENGTH) {
break;
}
relevantPosts.push(`- ${post}`);
contextLength += post.length;
}
message += relevantPosts.join('\n');
message += '\n\n---\n\n';
}
if (!sourceContent) {
if (context) {
return `Create a post about the following:\n\n${context}`;
message += `Create a post about the following:\n\n${context}`;
} else {
message += 'Create an engaging post for your followers.';
}
return 'Create an engaging post for your followers.';
return message;
}
const truncatedContent = truncateContent(sourceContent.content || '');
let message = `Create a post about the following content:\n\n`;
message += `Create a post about the following content:\n\n`;
message += `Title: ${sourceContent.title}\n`;
message += `URL: ${sourceContent.url}\n`;
@@ -426,16 +475,19 @@ export class ContentGenerator {
*
* @param sourceContent - Optional source content to post about
* @param context - Optional additional context
* @param previousPosts - Optional array of previous post contents for context
* @returns Generated content with token usage
*
* Validates: Requirements 3.2, 11.1, 11.2, 11.3
*/
async generatePost(
sourceContent?: ContentItem,
context?: string
context?: string,
previousPosts?: string[]
): Promise<GeneratedContent> {
const systemPrompt = buildPostSystemPrompt(this.bot.personalityConfig);
const userMessage = buildPostUserMessage(sourceContent, context);
const hasSourceContent = !!sourceContent;
const systemPrompt = buildPostSystemPrompt(this.bot.personalityConfig, hasSourceContent);
const userMessage = buildPostUserMessage(sourceContent, context, previousPosts);
const messages: LLMMessage[] = [
{ role: 'system', content: systemPrompt },
+69 -20
View File
@@ -261,6 +261,40 @@ async function getNextUnprocessedContentItem(botId: string): Promise<ContentItem
return null;
}
/**
* Get the bot's previous posts for context.
* Returns the content of the most recent posts to help avoid repetition.
*
* @param botId - The bot ID
* @param limit - Maximum number of posts to fetch (default: 40)
* @returns Array of post content strings
*/
async function getBotPreviousPosts(botId: string, limit: number = 40): Promise<string[]> {
// Get bot to find its user ID
const bot = await db.query.bots.findFirst({
where: eq(bots.id, botId),
});
if (!bot) {
return [];
}
// Get the bot's recent posts
const recentPosts = await db.query.posts.findMany({
where: eq(posts.userId, bot.userId),
orderBy: (posts, { desc }) => [desc(posts.createdAt)],
limit,
columns: {
content: true,
},
});
// Return just the content strings
return recentPosts
.map(p => p.content)
.filter((content): content is string => !!content);
}
/**
* Mark a content item as processed.
*
@@ -507,6 +541,7 @@ export async function selectContentForPosting(
* @param bot - The bot (from database)
* @param contentItem - Optional content item to post about
* @param context - Optional additional context
* @param previousPosts - Optional array of previous post contents for context
* @returns Generated post text
*
* Validates: Requirements 11.6
@@ -514,7 +549,8 @@ export async function selectContentForPosting(
export async function generatePostContent(
bot: typeof bots.$inferSelect & { user: { handle: string } },
contentItem?: ContentItem,
context?: string
context?: string,
previousPosts?: string[]
): Promise<string> {
// Check if bot has API key
try {
@@ -539,7 +575,7 @@ export async function generatePostContent(
try {
// Generate post (Requirement 11.6)
const generatedContent = await generator.generatePost(contentItem, context);
const generatedContent = await generator.generatePost(contentItem, context, previousPosts);
// Log the generation
await logActivity(
@@ -550,6 +586,7 @@ export async function generatePostContent(
contentItemId: contentItem?.id,
tokensUsed: generatedContent.tokensUsed,
model: generatedContent.model,
previousPostsCount: previousPosts?.length || 0,
},
true
);
@@ -995,15 +1032,21 @@ export async function triggerPost(
});
}
// Select content (Requirement 5.4)
const contentItem = await selectContentForPosting(botId, sourceContentId);
if (!contentItem) {
return {
success: false,
error: 'No content available for posting',
errorCode: 'NO_CONTENT',
};
// Select content (Requirement 5.4) - optional, bots can post without sources
let contentItem: ContentItem | null = null;
if (sourceContentId) {
contentItem = await selectContentForPosting(botId, sourceContentId);
if (!contentItem) {
return {
success: false,
error: 'Specified content not found',
errorCode: 'CONTENT_NOT_FOUND',
};
}
} else {
// Try to get content, but don't fail if none available
contentItem = await selectContentForPosting(botId);
}
// Get bot from database for generation
@@ -1020,8 +1063,12 @@ export async function triggerPost(
};
}
// Fetch previous posts for context (helps avoid repetition)
const previousPosts = await getBotPreviousPosts(botId, 40);
// Generate post content (Requirement 11.6)
let postContent = await generatePostContent(dbBot, contentItem, context);
// Content item is optional - bot can generate posts based on personality alone
let postContent = await generatePostContent(dbBot, contentItem || undefined, context, previousPosts);
// Sanitize content
postContent = sanitizePostContent(postContent);
@@ -1036,7 +1083,7 @@ export async function triggerPost(
'error',
{
type: 'validation',
contentItemId: contentItem.id,
contentItemId: contentItem?.id || null,
errors: validation.errors,
content: postContent,
},
@@ -1048,21 +1095,23 @@ export async function triggerPost(
success: false,
error: `Post validation failed: ${validation.errors.join(', ')}`,
errorCode: 'VALIDATION_FAILED',
contentItem,
contentItem: contentItem || undefined,
};
}
}
// Create post in database with source URL for link preview
const post = await createPostInDatabase(botId, postContent, contentItem.url);
// Create post in database with source URL for link preview (if content item exists)
const post = await createPostInDatabase(botId, postContent, contentItem?.url);
// Record post for rate limiting
if (!skipRateLimitCheck) {
await recordPost(botId);
}
// Mark content item as processed
await markContentItemProcessed(contentItem.id, post.id);
// Mark content item as processed (only if we used one)
if (contentItem) {
await markContentItemProcessed(contentItem.id, post.id);
}
// Log successful post creation
await logActivity(
@@ -1070,7 +1119,7 @@ export async function triggerPost(
'post_created',
{
postId: post.id,
contentItemId: contentItem.id,
contentItemId: contentItem?.id || null,
contentLength: postContent.length,
},
true
@@ -1082,7 +1131,7 @@ export async function triggerPost(
return {
success: true,
post,
contentItem,
contentItem: contentItem || undefined,
};
} catch (error) {
// Log error