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:
parent
63167287ca
commit
73df1ad214
9 changed files with 621 additions and 19 deletions
20
drizzle/0003_add_threads_and_reactions.sql
Normal file
20
drizzle/0003_add_threads_and_reactions.sql
Normal 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);
|
||||
|
|
@ -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),
|
||||
}),
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
});
|
||||
}
|
||||
|
|
|
|||
53
web/src/components/Reactions.tsx
Normal file
53
web/src/components/Reactions.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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`);
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
145
web/src/pages/Thread.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue