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:
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 |
@@ -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)' }}>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user