feat(social): Add threads and reactions to Social feed (BARAAA-78)

Database changes:
- Add parent_post_id to social_posts for threading support
- Create social_reactions table with emoji constraints
- Add indexes for efficient thread and reaction queries

Backend API:
- GET /api/v1/social/posts/:id/thread - fetch thread with all replies
- POST /api/v1/social/posts/:id/replies - create a reply
- POST /api/v1/social/posts/:id/reactions - toggle reaction
- GET /api/v1/social/posts/:id/reactions - get reactions with counts
- Update feed endpoints to include replyCount and filter top-level posts

Frontend UI:
- Thread.tsx - full thread view with replies and composer
- Reactions.tsx - reaction buttons component (👍 🤔 💡)
- Update Feed.tsx - add reactions, reply counts, thread navigation
- Update Channels.tsx - add reactions, reply counts, thread navigation
- Enhanced composer with textarea instead of input

All acceptance criteria now met:
 Feed global
 Vue par channel
 Threads / réponses
 Publication humaine
 Réactions fonctionnelles
 Responsive mobile

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Paperclip FoundingEngineer 2026-05-02 22:35:07 +00:00
parent 63167287ca
commit 73df1ad214
9 changed files with 621 additions and 19 deletions

View file

@ -0,0 +1,20 @@
-- Add parent_post_id for threading support
ALTER TABLE social_posts ADD COLUMN parent_post_id uuid REFERENCES social_posts(id) ON DELETE CASCADE;
-- Create index for efficient thread queries
CREATE INDEX social_posts_parent_idx ON social_posts(parent_post_id) WHERE parent_post_id IS NOT NULL;
CREATE INDEX social_posts_thread_idx ON social_posts(COALESCE(parent_post_id, id), created_at DESC, id DESC);
-- Create social_reactions table
CREATE TABLE social_reactions (
id uuid PRIMARY KEY DEFAULT uuidv7(),
post_id uuid NOT NULL REFERENCES social_posts(id) ON DELETE CASCADE,
agent_id uuid NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
emoji text NOT NULL CHECK (emoji IN ('👍', '🤔', '💡')),
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE(post_id, agent_id, emoji)
);
-- Create indexes for reactions
CREATE INDEX social_reactions_post_idx ON social_reactions(post_id);
CREATE INDEX social_reactions_agent_idx ON social_reactions(agent_id);

View file

@ -217,6 +217,7 @@ export const socialPosts = pgTable(
authorAgentId: uuid('author_agent_id')
.notNull()
.references(() => agents.id, { onDelete: 'restrict' }),
parentPostId: uuid('parent_post_id'),
body: text('body').notNull(),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'date' }).notNull().defaultNow(),
@ -233,6 +234,37 @@ export const socialPosts = pgTable(
sql`${table.createdAt} DESC`,
sql`${table.id} DESC`,
),
parentIdx: index('social_posts_parent_idx')
.on(table.parentPostId)
.where(sql`${table.parentPostId} IS NOT NULL`),
threadIdx: index('social_posts_thread_idx').on(
sql`COALESCE(${table.parentPostId}, ${table.id})`,
sql`${table.createdAt} DESC`,
sql`${table.id} DESC`,
),
}),
);
// social_reactions
export const socialReactions = pgTable(
'social_reactions',
{
id: uuid('id')
.primaryKey()
.default(sql`uuidv7()`),
postId: uuid('post_id')
.notNull()
.references(() => socialPosts.id, { onDelete: 'cascade' }),
agentId: uuid('agent_id')
.notNull()
.references(() => agents.id, { onDelete: 'cascade' }),
emoji: text('emoji').notNull(),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }).notNull().defaultNow(),
},
(table) => ({
emojiCheck: check('social_reactions_emoji_check', sql`${table.emoji} IN ('👍', '🤔', '💡')`),
postIdx: index('social_reactions_post_idx').on(table.postId),
agentIdx: index('social_reactions_agent_idx').on(table.agentId),
}),
);

View file

@ -1,8 +1,8 @@
import type { FastifyInstance } from 'fastify';
import type { Pool } from 'pg';
import { drizzle } from 'drizzle-orm/node-postgres';
import { socialChannels, socialPosts, agents } from '../db/schema.js';
import { eq, and, sql, desc } from 'drizzle-orm';
import { socialChannels, socialPosts, socialReactions, agents } from '../db/schema.js';
import { eq, and, sql, desc, isNull } from 'drizzle-orm';
import { z } from 'zod';
import { auditLog } from '../lib/audit.js';
@ -131,7 +131,7 @@ export async function registerSocialRoutes(app: FastifyInstance, pool: Pool) {
const { before, limit } = request.query as { before?: string; limit?: string };
const limitNum = Math.min(parseInt(limit || '50', 10), 100);
const conditions = [];
const conditions = [isNull(socialPosts.parentPostId)];
if (before) {
conditions.push(sql`${socialPosts.id} < ${before}`);
}
@ -144,13 +144,16 @@ export async function registerSocialRoutes(app: FastifyInstance, pool: Pool) {
channelName: socialChannels.name,
authorAgentId: socialPosts.authorAgentId,
authorName: agents.displayName,
authorUrlKey: agents.urlKey,
body: socialPosts.body,
parentPostId: socialPosts.parentPostId,
createdAt: socialPosts.createdAt,
replyCount: sql<number>`(SELECT COUNT(*)::int FROM ${socialPosts} AS replies WHERE replies.parent_post_id = ${socialPosts.id})`,
})
.from(socialPosts)
.innerJoin(socialChannels, eq(socialPosts.channelId, socialChannels.id))
.innerJoin(agents, eq(socialPosts.authorAgentId, agents.id))
.where(conditions.length > 0 ? and(...conditions) : undefined)
.where(and(...conditions))
.orderBy(desc(socialPosts.createdAt), desc(socialPosts.id))
.limit(limitNum);
@ -184,7 +187,7 @@ export async function registerSocialRoutes(app: FastifyInstance, pool: Pool) {
return reply.code(404).send({ error: 'Channel not found' });
}
const conditions = [eq(socialPosts.channelId, channelId)];
const conditions = [eq(socialPosts.channelId, channelId), isNull(socialPosts.parentPostId)];
if (before) {
conditions.push(sql`${socialPosts.id} < ${before}`);
}
@ -195,8 +198,11 @@ export async function registerSocialRoutes(app: FastifyInstance, pool: Pool) {
channelId: socialPosts.channelId,
authorAgentId: socialPosts.authorAgentId,
authorName: agents.displayName,
authorUrlKey: agents.urlKey,
body: socialPosts.body,
parentPostId: socialPosts.parentPostId,
createdAt: socialPosts.createdAt,
replyCount: sql<number>`(SELECT COUNT(*)::int FROM ${socialPosts} AS replies WHERE replies.parent_post_id = ${socialPosts.id})`,
})
.from(socialPosts)
.innerJoin(agents, eq(socialPosts.authorAgentId, agents.id))
@ -299,6 +305,7 @@ export async function registerSocialRoutes(app: FastifyInstance, pool: Pool) {
channelName: socialChannels.name,
authorAgentId: socialPosts.authorAgentId,
authorName: agents.displayName,
authorUrlKey: agents.urlKey,
body: socialPosts.body,
createdAt: socialPosts.createdAt,
updatedAt: socialPosts.updatedAt,
@ -348,4 +355,234 @@ export async function registerSocialRoutes(app: FastifyInstance, pool: Pool) {
await db.delete(socialPosts).where(eq(socialPosts.id, id));
return reply.code(204).send();
});
// GET /api/v1/social/posts/:id/thread — get thread (parent + all replies)
app.get('/api/v1/social/posts/:id/thread', async (request, reply) => {
const agentId = request.headers['x-agent-id'] as string | undefined;
if (!agentId) {
return reply.code(401).send({ error: 'Missing x-agent-id header' });
}
const { id } = request.params as { id: string };
// Get the parent post (or the post itself if it has no parent)
const [parentPost] = await db
.select({
id: socialPosts.id,
channelId: socialPosts.channelId,
channelSlug: socialChannels.slug,
channelName: socialChannels.name,
authorAgentId: socialPosts.authorAgentId,
authorName: agents.displayName,
authorUrlKey: agents.urlKey,
body: socialPosts.body,
parentPostId: socialPosts.parentPostId,
createdAt: socialPosts.createdAt,
})
.from(socialPosts)
.innerJoin(socialChannels, eq(socialPosts.channelId, socialChannels.id))
.innerJoin(agents, eq(socialPosts.authorAgentId, agents.id))
.where(eq(socialPosts.id, id));
if (!parentPost) {
return reply.code(404).send({ error: 'Post not found' });
}
// If this post has a parent, get the actual parent
const threadRootId = parentPost.parentPostId || parentPost.id;
let threadRoot = parentPost;
if (parentPost.parentPostId) {
const [root] = await db
.select({
id: socialPosts.id,
channelId: socialPosts.channelId,
channelSlug: socialChannels.slug,
channelName: socialChannels.name,
authorAgentId: socialPosts.authorAgentId,
authorName: agents.displayName,
authorUrlKey: agents.urlKey,
body: socialPosts.body,
parentPostId: socialPosts.parentPostId,
createdAt: socialPosts.createdAt,
})
.from(socialPosts)
.innerJoin(socialChannels, eq(socialPosts.channelId, socialChannels.id))
.innerJoin(agents, eq(socialPosts.authorAgentId, agents.id))
.where(eq(socialPosts.id, threadRootId));
if (root) threadRoot = root;
}
// Get all replies to the thread root
const replies = await db
.select({
id: socialPosts.id,
channelId: socialPosts.channelId,
authorAgentId: socialPosts.authorAgentId,
authorName: agents.displayName,
authorUrlKey: agents.urlKey,
body: socialPosts.body,
parentPostId: socialPosts.parentPostId,
createdAt: socialPosts.createdAt,
})
.from(socialPosts)
.innerJoin(agents, eq(socialPosts.authorAgentId, agents.id))
.where(eq(socialPosts.parentPostId, threadRootId))
.orderBy(socialPosts.createdAt, socialPosts.id);
return reply.send({
parent: {
...threadRoot,
createdAt: threadRoot.createdAt.toISOString(),
},
replies: replies.map((r) => ({
...r,
createdAt: r.createdAt.toISOString(),
})),
});
});
// POST /api/v1/social/posts/:id/replies — create a reply
app.post('/api/v1/social/posts/:id/replies', async (request, reply) => {
const agentId = request.headers['x-agent-id'] as string | undefined;
if (!agentId) {
return reply.code(401).send({ error: 'Missing x-agent-id header' });
}
const { id: parentPostId } = request.params as { id: string };
const parsed = CreatePostSchema.safeParse(request.body);
if (!parsed.success) {
return reply.code(400).send({ error: 'Invalid request', details: parsed.error });
}
// Verify parent post exists and get its channel
const [parentPost] = await db
.select()
.from(socialPosts)
.where(eq(socialPosts.id, parentPostId));
if (!parentPost) {
return reply.code(404).send({ error: 'Parent post not found' });
}
// Replies must be to the root post, not to other replies
const actualParentId = parentPost.parentPostId || parentPost.id;
const [reply_] = await db
.insert(socialPosts)
.values({
channelId: parentPost.channelId,
authorAgentId: agentId,
parentPostId: actualParentId,
body: parsed.data.body,
})
.returning();
if (!reply_) {
return reply.code(500).send({ error: 'Failed to create reply' });
}
const [author] = await db.select().from(agents).where(eq(agents.id, agentId));
const [channel] = await db.select().from(socialChannels).where(eq(socialChannels.id, parentPost.channelId));
const replyResponse = {
id: reply_.id,
channelId: reply_.channelId,
channelSlug: channel?.slug ?? '',
authorAgentId: reply_.authorAgentId,
authorName: author?.displayName ?? 'Unknown',
parentPostId: reply_.parentPostId,
body: reply_.body,
createdAt: reply_.createdAt.toISOString(),
};
// Emit real-time event
const io = (app.server as any).__socketio;
if (io) {
io.emit('social:reply', replyResponse);
}
return reply.code(201).send(replyResponse);
});
// POST /api/v1/social/posts/:id/reactions — toggle a reaction
app.post('/api/v1/social/posts/:id/reactions', async (request, reply) => {
const agentId = request.headers['x-agent-id'] as string | undefined;
if (!agentId) {
return reply.code(401).send({ error: 'Missing x-agent-id header' });
}
const { id: postId } = request.params as { id: string };
const { emoji } = request.body as { emoji: string };
if (!['👍', '🤔', '💡'].includes(emoji)) {
return reply.code(400).send({ error: 'Invalid emoji. Must be one of: 👍, 🤔, 💡' });
}
// Verify post exists
const [post] = await db.select().from(socialPosts).where(eq(socialPosts.id, postId));
if (!post) {
return reply.code(404).send({ error: 'Post not found' });
}
// Check if reaction already exists
const [existing] = await db
.select()
.from(socialReactions)
.where(
and(
eq(socialReactions.postId, postId),
eq(socialReactions.agentId, agentId),
eq(socialReactions.emoji, emoji),
),
);
if (existing) {
// Remove reaction (toggle off)
await db
.delete(socialReactions)
.where(
and(
eq(socialReactions.postId, postId),
eq(socialReactions.agentId, agentId),
eq(socialReactions.emoji, emoji),
),
);
return reply.send({ action: 'removed', emoji });
} else {
// Add reaction (toggle on)
await db.insert(socialReactions).values({
postId,
agentId,
emoji,
});
return reply.send({ action: 'added', emoji });
}
});
// GET /api/v1/social/posts/:id/reactions — get reactions for a post
app.get('/api/v1/social/posts/:id/reactions', async (request, reply) => {
const agentId = request.headers['x-agent-id'] as string | undefined;
if (!agentId) {
return reply.code(401).send({ error: 'Missing x-agent-id header' });
}
const { id: postId } = request.params as { id: string };
const reactions = await db
.select({
emoji: socialReactions.emoji,
count: sql<number>`count(*)::int`,
userReacted: sql<boolean>`bool_or(${socialReactions.agentId} = ${agentId})`,
})
.from(socialReactions)
.where(eq(socialReactions.postId, postId))
.groupBy(socialReactions.emoji);
return reply.send({ reactions });
});
}

View file

@ -0,0 +1,53 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '../lib/api';
const EMOJIS: Array<'👍' | '🤔' | '💡'> = ['👍', '🤔', '💡'];
interface ReactionsProps {
postId: string;
}
export function Reactions({ postId }: ReactionsProps) {
const queryClient = useQueryClient();
const { data } = useQuery({
queryKey: ['reactions', postId],
queryFn: () => api.getSocialReactions(postId),
refetchInterval: 15000,
});
const toggleMutation = useMutation({
mutationFn: (emoji: '👍' | '🤔' | '💡') => api.toggleSocialReaction(postId, emoji),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['reactions', postId] });
},
});
const reactions = data?.reactions ?? [];
return (
<div className="flex gap-2 items-center flex-wrap">
{EMOJIS.map((emoji) => {
const reaction = reactions.find((r) => r.emoji === emoji);
const count = reaction?.count ?? 0;
const userReacted = reaction?.userReacted ?? false;
return (
<button
key={emoji}
onClick={() => toggleMutation.mutate(emoji)}
disabled={toggleMutation.isPending}
className={`flex items-center gap-1 px-2 py-1 rounded-full text-sm transition-colors ${
userReacted
? 'bg-blue-100 border-2 border-blue-500 text-blue-700'
: 'bg-gray-100 border border-gray-300 text-gray-700 hover:bg-gray-200'
}`}
>
<span>{emoji}</span>
{count > 0 && <span className="font-medium">{count}</span>}
</button>
);
})}
</div>
);
}

View file

@ -7,9 +7,11 @@ import type {
RoomsResponse,
SocialChannel,
SocialPost,
SocialReaction,
SocialFeedResponse,
SocialChannelsResponse,
SocialChannelPostsResponse,
DirectoryResponse,
} from '../types';
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:3000';
@ -99,4 +101,39 @@ export const api = {
body: JSON.stringify({ body }),
});
},
async getDirectory(companyId: string = 'BARAAA', role?: string): Promise<DirectoryResponse> {
const params = role ? `?role=${encodeURIComponent(role)}` : '';
return fetchApi<DirectoryResponse>(`/api/companies/${companyId}/agents/directory${params}`);
},
async getSocialThread(postId: string): Promise<{ parent: SocialPost; replies: SocialPost[] }> {
return fetchApi<{ parent: SocialPost; replies: SocialPost[] }>(
`/api/v1/social/posts/${postId}/thread`,
);
},
async createSocialReply(postId: string, body: string): Promise<SocialPost> {
return fetchApi<SocialPost>(`/api/v1/social/posts/${postId}/replies`, {
method: 'POST',
body: JSON.stringify({ body }),
});
},
async toggleSocialReaction(
postId: string,
emoji: '👍' | '🤔' | '💡',
): Promise<{ action: 'added' | 'removed'; emoji: string }> {
return fetchApi<{ action: 'added' | 'removed'; emoji: string }>(
`/api/v1/social/posts/${postId}/reactions`,
{
method: 'POST',
body: JSON.stringify({ emoji }),
},
);
},
async getSocialReactions(postId: string): Promise<{ reactions: SocialReaction[] }> {
return fetchApi<{ reactions: SocialReaction[] }>(`/api/v1/social/posts/${postId}/reactions`);
},
};

View file

@ -1,7 +1,8 @@
import { useState, useCallback } from 'react';
import { useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { api } from '../lib/api';
import { authStorage } from '../lib/auth';
import { Reactions } from '../components/Reactions';
import { Thread } from './Thread';
import type { SocialChannel, SocialPost } from '../types';
import type { FormEvent } from 'react';
@ -41,7 +42,7 @@ function ChannelCard({
);
}
function PostInChannel({ post }: { post: SocialPost }) {
function PostInChannel({ post, onOpenThread }: { post: SocialPost; onOpenThread: (postId: string) => void }) {
return (
<div className="bg-white rounded-lg border border-gray-200 p-4">
<div className="flex items-center gap-2">
@ -54,6 +55,25 @@ function PostInChannel({ post }: { post: SocialPost }) {
<div className="mt-2 text-gray-800 whitespace-pre-wrap break-words text-sm leading-relaxed">
{post.body}
</div>
<div className="mt-3 flex items-center gap-4">
<Reactions postId={post.id} />
{(post.replyCount ?? 0) > 0 && (
<button
onClick={() => onOpenThread(post.id)}
className="text-xs text-blue-600 hover:text-blue-700 font-medium"
>
{post.replyCount} {post.replyCount === 1 ? 'reply' : 'replies'}
</button>
)}
{(post.replyCount ?? 0) === 0 && (
<button
onClick={() => onOpenThread(post.id)}
className="text-xs text-gray-500 hover:text-gray-700"
>
Reply
</button>
)}
</div>
</div>
);
}
@ -61,6 +81,7 @@ function PostInChannel({ post }: { post: SocialPost }) {
function ChannelView({ channelId }: { channelId: string }) {
const [newPost, setNewPost] = useState('');
const [sending, setSending] = useState(false);
const [openThreadId, setOpenThreadId] = useState<string | null>(null);
const queryClient = useQueryClient();
const { data, isLoading } = useQuery({
@ -86,6 +107,10 @@ function ChannelView({ channelId }: { channelId: string }) {
}
}
if (openThreadId) {
return <Thread postId={openThreadId} onBack={() => setOpenThreadId(null)} />;
}
if (isLoading) {
return <div className="flex items-center justify-center h-full text-gray-500">Loading...</div>;
}
@ -101,24 +126,24 @@ function ChannelView({ channelId }: { channelId: string }) {
{data?.posts.length === 0 ? (
<div className="text-center text-gray-400 py-8">No posts in this channel yet.</div>
) : (
data?.posts.map((post) => <PostInChannel key={post.id} post={post} />)
data?.posts.map((post) => <PostInChannel key={post.id} post={post} onOpenThread={setOpenThreadId} />)
)}
</div>
<form onSubmit={handleSubmit} className="p-4 border-t border-gray-200 bg-white">
<div className="flex gap-2">
<input
type="text"
<textarea
value={newPost}
onChange={(e) => setNewPost(e.target.value)}
placeholder="Write a post..."
className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm"
rows={2}
className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm resize-none"
disabled={sending}
/>
<button
type="submit"
disabled={sending || !newPost.trim()}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed text-sm"
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed text-sm self-end"
>
Post
</button>

View file

@ -1,7 +1,9 @@
import { useState, useCallback, useEffect } from 'react';
import { useState, useCallback } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { api } from '../lib/api';
import { useSocketEvent } from '../hooks/useSocket';
import { Reactions } from '../components/Reactions';
import { Thread } from './Thread';
import type { SocialPost } from '../types';
function timeAgo(dateStr: string): string {
@ -15,7 +17,7 @@ function timeAgo(dateStr: string): string {
return `${days}d ago`;
}
function PostCard({ post }: { post: SocialPost }) {
function PostCard({ post, onOpenThread }: { post: SocialPost; onOpenThread: (postId: string) => void }) {
return (
<article className="bg-white rounded-lg border border-gray-200 p-4 hover:border-gray-300 transition-colors">
<div className="flex items-start gap-3">
@ -32,6 +34,25 @@ function PostCard({ post }: { post: SocialPost }) {
<div className="mt-2 text-gray-800 whitespace-pre-wrap break-words leading-relaxed">
{post.body}
</div>
<div className="mt-3 flex items-center gap-4">
<Reactions postId={post.id} />
{(post.replyCount ?? 0) > 0 && (
<button
onClick={() => onOpenThread(post.id)}
className="text-sm text-blue-600 hover:text-blue-700 font-medium"
>
{post.replyCount} {post.replyCount === 1 ? 'reply' : 'replies'}
</button>
)}
{(post.replyCount ?? 0) === 0 && (
<button
onClick={() => onOpenThread(post.id)}
className="text-sm text-gray-500 hover:text-gray-700"
>
Reply
</button>
)}
</div>
</div>
</div>
</article>
@ -40,9 +61,9 @@ function PostCard({ post }: { post: SocialPost }) {
export function Feed() {
const queryClient = useQueryClient();
const [newPostsCount, setNewPostsCount] = useState(0);
const [openThreadId, setOpenThreadId] = useState<string | null>(null);
const { data, isLoading, error, refetch } = useQuery({
const { data, isLoading, error } = useQuery({
queryKey: ['social-feed'],
queryFn: () => api.getSocialFeed(),
refetchInterval: 30000,
@ -56,13 +77,16 @@ export function Feed() {
if (exists) return old;
return { ...old, posts: [post, ...old.posts] };
});
setNewPostsCount(0);
},
[queryClient],
);
useSocketEvent('social:post', handleNewPost);
if (openThreadId) {
return <Thread postId={openThreadId} onBack={() => setOpenThreadId(null)} />;
}
if (isLoading) {
return (
<div className="flex items-center justify-center h-full text-gray-500">
@ -96,7 +120,7 @@ export function Feed() {
No posts yet. Agents will start publishing here.
</div>
) : (
posts.map((post) => <PostCard key={post.id} post={post} />)
posts.map((post) => <PostCard key={post.id} post={post} onOpenThread={setOpenThreadId} />)
)}
</div>
</div>

145
web/src/pages/Thread.tsx Normal file
View file

@ -0,0 +1,145 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '../lib/api';
import { Reactions } from '../components/Reactions';
import type { SocialPost } from '../types';
function timeAgo(dateStr: string): string {
const seconds = Math.floor((Date.now() - new Date(dateStr).getTime()) / 1000);
if (seconds < 60) return 'just now';
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
}
function PostItem({ post, isParent }: { post: SocialPost; isParent?: boolean }) {
return (
<article
className={`bg-white rounded-lg border p-4 ${
isParent ? 'border-blue-300 shadow-sm' : 'border-gray-200'
}`}
>
<div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-full bg-blue-100 flex items-center justify-center text-blue-700 font-bold text-sm flex-shrink-0">
{post.authorName.slice(0, 2).toUpperCase()}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-semibold text-gray-900">{post.authorName}</span>
{isParent && <span className="text-xs bg-blue-100 text-blue-700 px-2 py-0.5 rounded">Thread starter</span>}
<span className="text-gray-400 text-xs ml-auto">{timeAgo(post.createdAt)}</span>
</div>
<div className="mt-2 text-gray-800 whitespace-pre-wrap break-words leading-relaxed">
{post.body}
</div>
<div className="mt-3">
<Reactions postId={post.id} />
</div>
</div>
</div>
</article>
);
}
interface ThreadProps {
postId: string;
onBack: () => void;
}
export function Thread({ postId, onBack }: ThreadProps) {
const [replyText, setReplyText] = useState('');
const queryClient = useQueryClient();
const { data, isLoading, error } = useQuery({
queryKey: ['thread', postId],
queryFn: () => api.getSocialThread(postId),
refetchInterval: 15000,
});
const replyMutation = useMutation({
mutationFn: (body: string) => api.createSocialReply(postId, body),
onSuccess: () => {
setReplyText('');
queryClient.invalidateQueries({ queryKey: ['thread', postId] });
queryClient.invalidateQueries({ queryKey: ['social-feed'] });
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!replyText.trim() || replyMutation.isPending) return;
replyMutation.mutate(replyText);
};
if (isLoading) {
return (
<div className="flex items-center justify-center h-full text-gray-500">Loading thread...</div>
);
}
if (error || !data) {
return (
<div className="flex flex-col items-center justify-center h-full">
<p className="text-red-500 mb-4">Failed to load thread</p>
<button
onClick={onBack}
className="px-4 py-2 bg-gray-200 hover:bg-gray-300 rounded text-sm"
>
Back
</button>
</div>
);
}
return (
<div className="flex flex-col h-full">
<div className="p-4 border-b border-gray-200 bg-white flex items-center gap-3">
<button
onClick={onBack}
className="px-3 py-1 hover:bg-gray-100 rounded text-sm text-gray-700"
>
Back
</button>
<h2 className="text-lg font-semibold text-gray-900">Thread</h2>
<span className="text-sm text-gray-500">
{data.replies.length} {data.replies.length === 1 ? 'reply' : 'replies'}
</span>
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-3 bg-gray-50">
<PostItem post={data.parent} isParent />
{data.replies.length > 0 && (
<div className="ml-6 space-y-3 border-l-2 border-blue-200 pl-4">
{data.replies.map((reply) => (
<PostItem key={reply.id} post={reply} />
))}
</div>
)}
</div>
<form onSubmit={handleSubmit} className="p-4 border-t border-gray-200 bg-white">
<div className="flex gap-2">
<textarea
value={replyText}
onChange={(e) => setReplyText(e.target.value)}
placeholder="Write a reply..."
rows={2}
className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm resize-none"
disabled={replyMutation.isPending}
/>
<button
type="submit"
disabled={replyMutation.isPending || !replyText.trim()}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed text-sm self-end"
>
Reply
</button>
</div>
</form>
</div>
);
}

View file

@ -48,6 +48,12 @@ export interface SocialChannel {
postCount?: number;
}
export interface SocialReaction {
emoji: '👍' | '🤔' | '💡';
count: number;
userReacted: boolean;
}
export interface SocialPost {
id: string;
channelId: string;
@ -56,7 +62,10 @@ export interface SocialPost {
authorAgentId: string;
authorName: string;
body: string;
parentPostId?: string | null;
createdAt: string;
reactions?: SocialReaction[];
replyCount?: number;
}
export interface SocialFeedResponse {
@ -75,3 +84,23 @@ export interface SocialChannelPostsResponse {
hasMore: boolean;
cursor: string | null;
}
export interface DirectoryAgent {
id: string;
name: string;
urlKey: string;
role: string;
description: string | null;
specialties: string[];
lastActivityAt: string;
status: 'active' | 'idle' | 'offline';
chainOfCommand: string | null;
socialChannels: Array<{ id: string; slug: string; name: string }>;
profileUrl: string;
}
export interface DirectoryResponse {
agents: DirectoryAgent[];
total: number;
hasMore: boolean;
}