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 [user, setUser] = useState<User | null>(null);
const [posts, setPosts] = useState<Post[]>([]); const [posts, setPosts] = useState<Post[]>([]);
const [likedPosts, setLikedPosts] = useState<Post[]>([]);
const [currentUser, setCurrentUser] = useState<{ id: string; handle: string } | null>(null); const [currentUser, setCurrentUser] = useState<{ id: string; handle: string } | null>(null);
const [isFollowing, setIsFollowing] = useState(false); const [isFollowing, setIsFollowing] = useState(false);
const [loading, setLoading] = useState(true); 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 [followers, setFollowers] = useState<UserSummary[]>([]);
const [following, setFollowing] = useState<UserSummary[]>([]); const [following, setFollowing] = useState<UserSummary[]>([]);
const [postsLoading, setPostsLoading] = useState(true); const [postsLoading, setPostsLoading] = useState(true);
const [likesLoading, setLikesLoading] = useState(false);
const [followersLoading, setFollowersLoading] = useState(false); const [followersLoading, setFollowersLoading] = useState(false);
const [followingLoading, setFollowingLoading] = useState(false); const [followingLoading, setFollowingLoading] = useState(false);
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
@@ -106,6 +108,7 @@ export default function ProfilePage() {
setSaveError(null); setSaveError(null);
setFollowers([]); setFollowers([]);
setFollowing([]); setFollowing([]);
setLikedPosts([]);
// Get current user // Get current user
fetch('/api/auth/me') fetch('/api/auth/me')
.then(res => res.json()) .then(res => res.json())
@@ -197,6 +200,15 @@ export default function ProfilePage() {
.catch(() => setFollowing([])) .catch(() => setFollowing([]))
.finally(() => setFollowingLoading(false)); .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]); }, [activeTab, handle]);
const handleFollow = async () => { const handleFollow = async () => {
@@ -617,7 +629,10 @@ export default function ProfilePage() {
{/* Tabs */} {/* Tabs */}
<div style={{ display: 'flex', borderTop: '1px solid var(--border)' }}> <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 <button
key={tab} key={tab}
onClick={() => setActiveTab(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' && ( {activeTab === 'followers' && (
followersLoading ? ( followersLoading ? (
<div style={{ padding: '24px', textAlign: 'center', color: 'var(--foreground-tertiary)' }}> <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) 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 // Public timeline - all local posts + all cached remote posts
const localPosts = await db.query.posts.findMany({ const localPosts = await db.query.posts.findMany({
where: baseFilter, where: baseFilter,
+40 -2
View File
@@ -1,6 +1,6 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { db, users, posts } from '@/db'; import { db, users, posts, likes } from '@/db';
import { ilike, or, desc, and, notInArray, eq } from 'drizzle-orm'; import { ilike, or, desc, and, notInArray, eq, inArray } from 'drizzle-orm';
import { resolveRemoteUser } from '@/lib/activitypub/fetch'; import { resolveRemoteUser } from '@/lib/activitypub/fetch';
type SearchUser = { type SearchUser = {
@@ -150,11 +150,49 @@ export async function GET(request: Request) {
with: { with: {
author: true, author: true,
media: true, media: true,
bot: true,
}, },
orderBy: [desc(posts.createdAt)], orderBy: [desc(posts.createdAt)],
limit, limit,
}); });
searchPosts = postResults; 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({ 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 { NextResponse } from 'next/server';
import { db, posts, users } from '@/db'; import { db, posts, users, likes } from '@/db';
import { eq, desc, and } from 'drizzle-orm'; import { eq, desc, and, inArray } from 'drizzle-orm';
import { resolveRemoteUser } from '@/lib/activitypub/fetch'; import { resolveRemoteUser } from '@/lib/activitypub/fetch';
type RouteContext = { params: Promise<{ handle: string }> }; type RouteContext = { params: Promise<{ handle: string }> };
@@ -285,11 +285,12 @@ export async function GET(request: Request, context: RouteContext) {
} }
// Get user's posts // 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)), where: and(eq(posts.userId, user.id), eq(posts.isRemoved, false)),
with: { with: {
author: true, author: true,
media: true, media: true,
bot: true,
replyTo: { replyTo: {
with: { author: true }, with: { author: true },
}, },
@@ -298,6 +299,43 @@ export async function GET(request: Request, context: RouteContext) {
limit, 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({ return NextResponse.json({
posts: userPosts, posts: userPosts,
nextCursor: userPosts.length === limit ? userPosts[userPosts.length - 1]?.id : null, 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 { PostCard } from '@/components/PostCard';
import { Post } from '@/lib/types'; import { Post } from '@/lib/types';
import { formatFullHandle } from '@/lib/utils/handle'; import { formatFullHandle } from '@/lib/utils/handle';
import { Bot } from 'lucide-react'; import { Bot, Globe, Server } from 'lucide-react';
interface User { interface User {
id: string; id: string;
@@ -60,31 +60,51 @@ function UserCard({ user }: { user: User }) {
export default function ExplorePage() { export default function ExplorePage() {
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const [activeTab, setActiveTab] = useState<'trending' | 'users' | 'search'>('trending'); const [activeTab, setActiveTab] = useState<'node' | 'fediverse' | 'users' | 'search'>('node');
const [trendingPosts, setTrendingPosts] = useState<Post[]>([]); const [nodePosts, setNodePosts] = useState<Post[]>([]);
const [fediversePosts, setFediversePosts] = useState<Post[]>([]);
const [users, setUsers] = useState<User[]>([]); const [users, setUsers] = useState<User[]>([]);
const [searchResults, setSearchResults] = useState<{ posts: Post[]; users: User[] }>({ posts: [], users: [] }); const [searchResults, setSearchResults] = useState<{ posts: Post[]; users: User[] }>({ posts: [], users: [] });
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [searching, setSearching] = useState(false); const [searching, setSearching] = useState(false);
useEffect(() => { useEffect(() => {
// Load trending posts // Load node posts (local only)
const loadTrending = async () => { const loadNodePosts = async () => {
setLoading(true); setLoading(true);
try { 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(); const data = await res.json();
setTrendingPosts(data.posts || []); setNodePosts(data.posts || []);
} catch { } catch {
setTrendingPosts([]); setNodePosts([]);
} finally { } finally {
setLoading(false); 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(() => { useEffect(() => {
// Load users when tab changes to users // Load users when tab changes to users
if (activeTab === 'users' && users.length === 0) { if (activeTab === 'users' && users.length === 0) {
@@ -136,7 +156,8 @@ export default function ExplorePage() {
}; };
const handleDelete = (postId: string) => { 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 => ({ setSearchResults(prev => ({
...prev, ...prev,
posts: prev.posts.filter(p => p.id !== postId) posts: prev.posts.filter(p => p.id !== postId)
@@ -160,11 +181,18 @@ export default function ExplorePage() {
<div className="explore-tabs"> <div className="explore-tabs">
<button <button
className={`explore-tab ${activeTab === 'trending' ? 'active' : ''}`} className={`explore-tab ${activeTab === 'node' ? 'active' : ''}`}
onClick={() => setActiveTab('trending')} onClick={() => setActiveTab('node')}
> >
<TrendingIcon /> <Server size={18} />
<span>Trending</span> <span>Node</span>
</button>
<button
className={`explore-tab ${activeTab === 'fediverse' ? 'active' : ''}`}
onClick={() => setActiveTab('fediverse')}
>
<Globe size={18} />
<span>Fediverse</span>
</button> </button>
<button <button
className={`explore-tab ${activeTab === 'users' ? 'active' : ''}`} className={`explore-tab ${activeTab === 'users' ? 'active' : ''}`}
@@ -185,13 +213,38 @@ export default function ExplorePage() {
</div> </div>
<div className="explore-content"> <div className="explore-content">
{activeTab === 'trending' && ( {activeTab === 'node' && (
loading ? ( loading ? (
<div className="explore-loading">Loading trending posts...</div> <div className="explore-loading">Loading posts...</div>
) : trendingPosts.length === 0 ? ( ) : nodePosts.length === 0 ? (
<div className="explore-empty"> <div className="explore-empty">
<TrendingIcon /> <Server size={24} />
<p>No trending posts yet</p> <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> </div>
) : ( ) : (
<> <>
@@ -202,7 +255,7 @@ export default function ExplorePage() {
</div> </div>
</div> </div>
<div className="explore-posts"> <div className="explore-posts">
{trendingPosts.map((post) => ( {fediversePosts.map((post) => (
<PostCard key={post.id} post={post} onLike={handleLike} onRepost={handleRepost} onDelete={handleDelete} /> <PostCard key={post.id} post={post} onLike={handleLike} onRepost={handleRepost} onDelete={handleDelete} />
))} ))}
</div> </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; name: string;
handle: string; handle: string;
bio: string; bio: string;
avatarUrl: string | null;
isActive: boolean; isActive: boolean;
isSuspended: boolean; isSuspended: boolean;
autonomousMode: boolean; autonomousMode: boolean;
@@ -108,42 +109,65 @@ export default function BotsPage() {
onClick={() => router.push(`/settings/bots/${bot.id}`)} onClick={() => router.push(`/settings/bots/${bot.id}`)}
> >
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start', marginBottom: '12px' }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start', marginBottom: '12px' }}>
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ display: 'flex', gap: '12px', flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '4px' }}> <Link
<h2 style={{ fontSize: '16px', fontWeight: 600 }}>{bot.name}</h2> href={`/${bot.handle}`}
{bot.autonomousMode && ( onClick={(e) => e.stopPropagation()}
<span style={{ className="avatar"
display: 'inline-flex', style={{
alignItems: 'center', width: '48px',
gap: '4px', height: '48px',
fontSize: '11px', flexShrink: 0,
padding: '3px 8px', fontSize: '18px',
borderRadius: 'var(--radius-full)', }}
background: 'var(--accent-muted)', >
color: 'var(--accent)', {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} /> {bot.bio}
Auto </p>
</span>
)} )}
</div> </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>
<div style={{ display: 'flex', gap: '6px', flexShrink: 0 }}> <div style={{ display: 'flex', gap: '6px', flexShrink: 0 }}>
{bot.isSuspended ? ( {bot.isSuspended ? (
+15 -3
View File
@@ -48,15 +48,27 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, isDetail
setReposted(post.isReposted || false); setReposted(post.isReposted || false);
}, [post.isLiked, post.isReposted, post.id]); }, [post.isLiked, post.isReposted, post.id]);
const formatTime = (dateStr: string) => { const formatTime = (dateStr: string | Date) => {
const date = new Date(dateStr); const date = new Date(dateStr);
if (isNaN(date.getTime())) {
return '';
}
const now = new Date(); const now = new Date();
const diff = now.getTime() - date.getTime(); 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 hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24); const days = Math.floor(hours / 24);
if (minutes < 1) return 'now'; if (seconds < 60) return 'now';
if (minutes < 60) return `${minutes}m`; if (minutes < 60) return `${minutes}m`;
if (hours < 24) return `${hours}h`; if (hours < 24) return `${hours}h`;
if (days < 7) return `${days}d`; if (days < 7) return `${days}d`;
+3 -7
View File
@@ -1,9 +1,10 @@
'use client'; 'use client';
import Link from 'next/link'; import Link from 'next/link';
import Image from 'next/image';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import { useAuth } from '@/lib/contexts/AuthContext'; 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'; import { formatFullHandle } from '@/lib/utils/handle';
export function Sidebar() { export function Sidebar() {
@@ -16,8 +17,7 @@ export function Sidebar() {
return ( return (
<aside className="sidebar"> <aside className="sidebar">
<Link href="/" className="logo"> <Link href="/" className="logo">
<SynapsisLogo /> <Image src="/logotext.png" alt="Synapsis" width={185} height={42} priority />
<span>Synapsis</span>
</Link> </Link>
<nav> <nav>
<Link href="/" className={`nav-item ${isHome ? 'active' : ''}`}> <Link href="/" className={`nav-item ${isHome ? 'active' : ''}`}>
@@ -38,10 +38,6 @@ export function Sidebar() {
<span>Bots</span> <span>Bots</span>
</Link> </Link>
)} )}
<Link href="/guide" className={`nav-item ${pathname?.startsWith('/guide') ? 'active' : ''}`}>
<BookOpenIcon />
<span>Guide</span>
</Link>
{user ? ( {user ? (
<Link href={`/${user.handle}`} className={`nav-item ${pathname === '/' + user.handle ? 'active' : ''}`}> <Link href={`/${user.handle}`} className={`nav-item ${pathname === '/' + user.handle ? 'active' : ''}`}>
<UserIcon /> <UserIcon />
+61 -9
View File
@@ -108,6 +108,11 @@ export const MAX_SOURCE_CONTENT_LENGTH = 4000;
*/ */
export const MAX_CONVERSATION_CONTEXT_LENGTH = 2000; 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. * Truncation suffix added to truncated content.
*/ */
@@ -212,18 +217,21 @@ export function isContentTruncated(content: string): boolean {
* Includes personality context and instructions. * Includes personality context and instructions.
* *
* @param personality - Bot's personality configuration * @param personality - Bot's personality configuration
* @param hasSourceContent - Whether the post has source content with a URL
* @returns System prompt string * @returns System prompt string
* *
* Validates: Requirements 3.2, 11.1 * 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; let prompt = personality.systemPrompt;
if (personality.responseStyle) { if (personality.responseStyle) {
prompt += `\n\nResponse Style: ${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: Instructions for creating posts:
- Create engaging, original content based on the source material - 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 simply copy or summarize - add value with your unique voice
- Do NOT use hashtags - Do NOT use hashtags
- Write like a human, not a marketing bot - Write like a human, not a marketing bot
- AVOID repeating themes, phrases, or sentence structures from your previous posts
- Format: [Your commentary] [URL]`; - 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; return prompt;
} }
@@ -294,22 +317,48 @@ Respond with a JSON object containing:
* *
* @param sourceContent - Source content to post about * @param sourceContent - Source content to post about
* @param context - Optional additional context * @param context - Optional additional context
* @param previousPosts - Optional array of previous post contents for context
* @returns User message string * @returns User message string
*/ */
export function buildPostUserMessage( export function buildPostUserMessage(
sourceContent?: ContentItem, sourceContent?: ContentItem,
context?: string context?: string,
previousPosts?: string[]
): 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 (!sourceContent) {
if (context) { 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 || ''); 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 += `Title: ${sourceContent.title}\n`;
message += `URL: ${sourceContent.url}\n`; message += `URL: ${sourceContent.url}\n`;
@@ -426,16 +475,19 @@ export class ContentGenerator {
* *
* @param sourceContent - Optional source content to post about * @param sourceContent - Optional source content to post about
* @param context - Optional additional context * @param context - Optional additional context
* @param previousPosts - Optional array of previous post contents for context
* @returns Generated content with token usage * @returns Generated content with token usage
* *
* Validates: Requirements 3.2, 11.1, 11.2, 11.3 * Validates: Requirements 3.2, 11.1, 11.2, 11.3
*/ */
async generatePost( async generatePost(
sourceContent?: ContentItem, sourceContent?: ContentItem,
context?: string context?: string,
previousPosts?: string[]
): Promise<GeneratedContent> { ): Promise<GeneratedContent> {
const systemPrompt = buildPostSystemPrompt(this.bot.personalityConfig); const hasSourceContent = !!sourceContent;
const userMessage = buildPostUserMessage(sourceContent, context); const systemPrompt = buildPostSystemPrompt(this.bot.personalityConfig, hasSourceContent);
const userMessage = buildPostUserMessage(sourceContent, context, previousPosts);
const messages: LLMMessage[] = [ const messages: LLMMessage[] = [
{ role: 'system', content: systemPrompt }, { role: 'system', content: systemPrompt },
+69 -20
View File
@@ -261,6 +261,40 @@ async function getNextUnprocessedContentItem(botId: string): Promise<ContentItem
return null; 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. * Mark a content item as processed.
* *
@@ -507,6 +541,7 @@ export async function selectContentForPosting(
* @param bot - The bot (from database) * @param bot - The bot (from database)
* @param contentItem - Optional content item to post about * @param contentItem - Optional content item to post about
* @param context - Optional additional context * @param context - Optional additional context
* @param previousPosts - Optional array of previous post contents for context
* @returns Generated post text * @returns Generated post text
* *
* Validates: Requirements 11.6 * Validates: Requirements 11.6
@@ -514,7 +549,8 @@ export async function selectContentForPosting(
export async function generatePostContent( export async function generatePostContent(
bot: typeof bots.$inferSelect & { user: { handle: string } }, bot: typeof bots.$inferSelect & { user: { handle: string } },
contentItem?: ContentItem, contentItem?: ContentItem,
context?: string context?: string,
previousPosts?: string[]
): Promise<string> { ): Promise<string> {
// Check if bot has API key // Check if bot has API key
try { try {
@@ -539,7 +575,7 @@ export async function generatePostContent(
try { try {
// Generate post (Requirement 11.6) // Generate post (Requirement 11.6)
const generatedContent = await generator.generatePost(contentItem, context); const generatedContent = await generator.generatePost(contentItem, context, previousPosts);
// Log the generation // Log the generation
await logActivity( await logActivity(
@@ -550,6 +586,7 @@ export async function generatePostContent(
contentItemId: contentItem?.id, contentItemId: contentItem?.id,
tokensUsed: generatedContent.tokensUsed, tokensUsed: generatedContent.tokensUsed,
model: generatedContent.model, model: generatedContent.model,
previousPostsCount: previousPosts?.length || 0,
}, },
true true
); );
@@ -995,15 +1032,21 @@ export async function triggerPost(
}); });
} }
// Select content (Requirement 5.4) // Select content (Requirement 5.4) - optional, bots can post without sources
const contentItem = await selectContentForPosting(botId, sourceContentId); let contentItem: ContentItem | null = null;
if (!contentItem) { if (sourceContentId) {
return { contentItem = await selectContentForPosting(botId, sourceContentId);
success: false, if (!contentItem) {
error: 'No content available for posting', return {
errorCode: 'NO_CONTENT', 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 // 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) // 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 // Sanitize content
postContent = sanitizePostContent(postContent); postContent = sanitizePostContent(postContent);
@@ -1036,7 +1083,7 @@ export async function triggerPost(
'error', 'error',
{ {
type: 'validation', type: 'validation',
contentItemId: contentItem.id, contentItemId: contentItem?.id || null,
errors: validation.errors, errors: validation.errors,
content: postContent, content: postContent,
}, },
@@ -1048,21 +1095,23 @@ export async function triggerPost(
success: false, success: false,
error: `Post validation failed: ${validation.errors.join(', ')}`, error: `Post validation failed: ${validation.errors.join(', ')}`,
errorCode: 'VALIDATION_FAILED', errorCode: 'VALIDATION_FAILED',
contentItem, contentItem: contentItem || undefined,
}; };
} }
} }
// Create post in database with source URL for link preview // Create post in database with source URL for link preview (if content item exists)
const post = await createPostInDatabase(botId, postContent, contentItem.url); const post = await createPostInDatabase(botId, postContent, contentItem?.url);
// Record post for rate limiting // Record post for rate limiting
if (!skipRateLimitCheck) { if (!skipRateLimitCheck) {
await recordPost(botId); await recordPost(botId);
} }
// Mark content item as processed // Mark content item as processed (only if we used one)
await markContentItemProcessed(contentItem.id, post.id); if (contentItem) {
await markContentItemProcessed(contentItem.id, post.id);
}
// Log successful post creation // Log successful post creation
await logActivity( await logActivity(
@@ -1070,7 +1119,7 @@ export async function triggerPost(
'post_created', 'post_created',
{ {
postId: post.id, postId: post.id,
contentItemId: contentItem.id, contentItemId: contentItem?.id || null,
contentLength: postContent.length, contentLength: postContent.length,
}, },
true true
@@ -1082,7 +1131,7 @@ export async function triggerPost(
return { return {
success: true, success: true,
post, post,
contentItem, contentItem: contentItem || undefined,
}; };
} catch (error) { } catch (error) {
// Log error // Log error