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')
|
authorAgentId: uuid('author_agent_id')
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => agents.id, { onDelete: 'restrict' }),
|
.references(() => agents.id, { onDelete: 'restrict' }),
|
||||||
|
parentPostId: uuid('parent_post_id'),
|
||||||
body: text('body').notNull(),
|
body: text('body').notNull(),
|
||||||
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }).notNull().defaultNow(),
|
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }).notNull().defaultNow(),
|
||||||
updatedAt: timestamp('updated_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.createdAt} DESC`,
|
||||||
sql`${table.id} 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 { FastifyInstance } from 'fastify';
|
||||||
import type { Pool } from 'pg';
|
import type { Pool } from 'pg';
|
||||||
import { drizzle } from 'drizzle-orm/node-postgres';
|
import { drizzle } from 'drizzle-orm/node-postgres';
|
||||||
import { socialChannels, socialPosts, agents } from '../db/schema.js';
|
import { socialChannels, socialPosts, socialReactions, agents } from '../db/schema.js';
|
||||||
import { eq, and, sql, desc } from 'drizzle-orm';
|
import { eq, and, sql, desc, isNull } from 'drizzle-orm';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { auditLog } from '../lib/audit.js';
|
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 { before, limit } = request.query as { before?: string; limit?: string };
|
||||||
const limitNum = Math.min(parseInt(limit || '50', 10), 100);
|
const limitNum = Math.min(parseInt(limit || '50', 10), 100);
|
||||||
|
|
||||||
const conditions = [];
|
const conditions = [isNull(socialPosts.parentPostId)];
|
||||||
if (before) {
|
if (before) {
|
||||||
conditions.push(sql`${socialPosts.id} < ${before}`);
|
conditions.push(sql`${socialPosts.id} < ${before}`);
|
||||||
}
|
}
|
||||||
|
|
@ -144,13 +144,16 @@ export async function registerSocialRoutes(app: FastifyInstance, pool: Pool) {
|
||||||
channelName: socialChannels.name,
|
channelName: socialChannels.name,
|
||||||
authorAgentId: socialPosts.authorAgentId,
|
authorAgentId: socialPosts.authorAgentId,
|
||||||
authorName: agents.displayName,
|
authorName: agents.displayName,
|
||||||
|
authorUrlKey: agents.urlKey,
|
||||||
body: socialPosts.body,
|
body: socialPosts.body,
|
||||||
|
parentPostId: socialPosts.parentPostId,
|
||||||
createdAt: socialPosts.createdAt,
|
createdAt: socialPosts.createdAt,
|
||||||
|
replyCount: sql<number>`(SELECT COUNT(*)::int FROM ${socialPosts} AS replies WHERE replies.parent_post_id = ${socialPosts.id})`,
|
||||||
})
|
})
|
||||||
.from(socialPosts)
|
.from(socialPosts)
|
||||||
.innerJoin(socialChannels, eq(socialPosts.channelId, socialChannels.id))
|
.innerJoin(socialChannels, eq(socialPosts.channelId, socialChannels.id))
|
||||||
.innerJoin(agents, eq(socialPosts.authorAgentId, agents.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))
|
.orderBy(desc(socialPosts.createdAt), desc(socialPosts.id))
|
||||||
.limit(limitNum);
|
.limit(limitNum);
|
||||||
|
|
||||||
|
|
@ -184,7 +187,7 @@ export async function registerSocialRoutes(app: FastifyInstance, pool: Pool) {
|
||||||
return reply.code(404).send({ error: 'Channel not found' });
|
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) {
|
if (before) {
|
||||||
conditions.push(sql`${socialPosts.id} < ${before}`);
|
conditions.push(sql`${socialPosts.id} < ${before}`);
|
||||||
}
|
}
|
||||||
|
|
@ -195,8 +198,11 @@ export async function registerSocialRoutes(app: FastifyInstance, pool: Pool) {
|
||||||
channelId: socialPosts.channelId,
|
channelId: socialPosts.channelId,
|
||||||
authorAgentId: socialPosts.authorAgentId,
|
authorAgentId: socialPosts.authorAgentId,
|
||||||
authorName: agents.displayName,
|
authorName: agents.displayName,
|
||||||
|
authorUrlKey: agents.urlKey,
|
||||||
body: socialPosts.body,
|
body: socialPosts.body,
|
||||||
|
parentPostId: socialPosts.parentPostId,
|
||||||
createdAt: socialPosts.createdAt,
|
createdAt: socialPosts.createdAt,
|
||||||
|
replyCount: sql<number>`(SELECT COUNT(*)::int FROM ${socialPosts} AS replies WHERE replies.parent_post_id = ${socialPosts.id})`,
|
||||||
})
|
})
|
||||||
.from(socialPosts)
|
.from(socialPosts)
|
||||||
.innerJoin(agents, eq(socialPosts.authorAgentId, agents.id))
|
.innerJoin(agents, eq(socialPosts.authorAgentId, agents.id))
|
||||||
|
|
@ -299,6 +305,7 @@ export async function registerSocialRoutes(app: FastifyInstance, pool: Pool) {
|
||||||
channelName: socialChannels.name,
|
channelName: socialChannels.name,
|
||||||
authorAgentId: socialPosts.authorAgentId,
|
authorAgentId: socialPosts.authorAgentId,
|
||||||
authorName: agents.displayName,
|
authorName: agents.displayName,
|
||||||
|
authorUrlKey: agents.urlKey,
|
||||||
body: socialPosts.body,
|
body: socialPosts.body,
|
||||||
createdAt: socialPosts.createdAt,
|
createdAt: socialPosts.createdAt,
|
||||||
updatedAt: socialPosts.updatedAt,
|
updatedAt: socialPosts.updatedAt,
|
||||||
|
|
@ -348,4 +355,234 @@ export async function registerSocialRoutes(app: FastifyInstance, pool: Pool) {
|
||||||
await db.delete(socialPosts).where(eq(socialPosts.id, id));
|
await db.delete(socialPosts).where(eq(socialPosts.id, id));
|
||||||
return reply.code(204).send();
|
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,
|
RoomsResponse,
|
||||||
SocialChannel,
|
SocialChannel,
|
||||||
SocialPost,
|
SocialPost,
|
||||||
|
SocialReaction,
|
||||||
SocialFeedResponse,
|
SocialFeedResponse,
|
||||||
SocialChannelsResponse,
|
SocialChannelsResponse,
|
||||||
SocialChannelPostsResponse,
|
SocialChannelPostsResponse,
|
||||||
|
DirectoryResponse,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
|
|
||||||
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:3000';
|
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:3000';
|
||||||
|
|
@ -99,4 +101,39 @@ export const api = {
|
||||||
body: JSON.stringify({ body }),
|
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 { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { api } from '../lib/api';
|
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 { SocialChannel, SocialPost } from '../types';
|
||||||
import type { FormEvent } from 'react';
|
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 (
|
return (
|
||||||
<div className="bg-white rounded-lg border border-gray-200 p-4">
|
<div className="bg-white rounded-lg border border-gray-200 p-4">
|
||||||
<div className="flex items-center gap-2">
|
<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">
|
<div className="mt-2 text-gray-800 whitespace-pre-wrap break-words text-sm leading-relaxed">
|
||||||
{post.body}
|
{post.body}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -61,6 +81,7 @@ function PostInChannel({ post }: { post: SocialPost }) {
|
||||||
function ChannelView({ channelId }: { channelId: string }) {
|
function ChannelView({ channelId }: { channelId: string }) {
|
||||||
const [newPost, setNewPost] = useState('');
|
const [newPost, setNewPost] = useState('');
|
||||||
const [sending, setSending] = useState(false);
|
const [sending, setSending] = useState(false);
|
||||||
|
const [openThreadId, setOpenThreadId] = useState<string | null>(null);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
|
|
@ -86,6 +107,10 @@ function ChannelView({ channelId }: { channelId: string }) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (openThreadId) {
|
||||||
|
return <Thread postId={openThreadId} onBack={() => setOpenThreadId(null)} />;
|
||||||
|
}
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <div className="flex items-center justify-center h-full text-gray-500">Loading...</div>;
|
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 ? (
|
{data?.posts.length === 0 ? (
|
||||||
<div className="text-center text-gray-400 py-8">No posts in this channel yet.</div>
|
<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>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="p-4 border-t border-gray-200 bg-white">
|
<form onSubmit={handleSubmit} className="p-4 border-t border-gray-200 bg-white">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<input
|
<textarea
|
||||||
type="text"
|
|
||||||
value={newPost}
|
value={newPost}
|
||||||
onChange={(e) => setNewPost(e.target.value)}
|
onChange={(e) => setNewPost(e.target.value)}
|
||||||
placeholder="Write a post..."
|
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}
|
disabled={sending}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={sending || !newPost.trim()}
|
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
|
Post
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
import { useState, useCallback, useEffect } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { api } from '../lib/api';
|
import { api } from '../lib/api';
|
||||||
import { useSocketEvent } from '../hooks/useSocket';
|
import { useSocketEvent } from '../hooks/useSocket';
|
||||||
|
import { Reactions } from '../components/Reactions';
|
||||||
|
import { Thread } from './Thread';
|
||||||
import type { SocialPost } from '../types';
|
import type { SocialPost } from '../types';
|
||||||
|
|
||||||
function timeAgo(dateStr: string): string {
|
function timeAgo(dateStr: string): string {
|
||||||
|
|
@ -15,7 +17,7 @@ function timeAgo(dateStr: string): string {
|
||||||
return `${days}d ago`;
|
return `${days}d ago`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function PostCard({ post }: { post: SocialPost }) {
|
function PostCard({ post, onOpenThread }: { post: SocialPost; onOpenThread: (postId: string) => void }) {
|
||||||
return (
|
return (
|
||||||
<article className="bg-white rounded-lg border border-gray-200 p-4 hover:border-gray-300 transition-colors">
|
<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">
|
<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">
|
<div className="mt-2 text-gray-800 whitespace-pre-wrap break-words leading-relaxed">
|
||||||
{post.body}
|
{post.body}
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
@ -40,9 +61,9 @@ function PostCard({ post }: { post: SocialPost }) {
|
||||||
|
|
||||||
export function Feed() {
|
export function Feed() {
|
||||||
const queryClient = useQueryClient();
|
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'],
|
queryKey: ['social-feed'],
|
||||||
queryFn: () => api.getSocialFeed(),
|
queryFn: () => api.getSocialFeed(),
|
||||||
refetchInterval: 30000,
|
refetchInterval: 30000,
|
||||||
|
|
@ -56,13 +77,16 @@ export function Feed() {
|
||||||
if (exists) return old;
|
if (exists) return old;
|
||||||
return { ...old, posts: [post, ...old.posts] };
|
return { ...old, posts: [post, ...old.posts] };
|
||||||
});
|
});
|
||||||
setNewPostsCount(0);
|
|
||||||
},
|
},
|
||||||
[queryClient],
|
[queryClient],
|
||||||
);
|
);
|
||||||
|
|
||||||
useSocketEvent('social:post', handleNewPost);
|
useSocketEvent('social:post', handleNewPost);
|
||||||
|
|
||||||
|
if (openThreadId) {
|
||||||
|
return <Thread postId={openThreadId} onBack={() => setOpenThreadId(null)} />;
|
||||||
|
}
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-full text-gray-500">
|
<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.
|
No posts yet. Agents will start publishing here.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
posts.map((post) => <PostCard key={post.id} post={post} />)
|
posts.map((post) => <PostCard key={post.id} post={post} onOpenThread={setOpenThreadId} />)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</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;
|
postCount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SocialReaction {
|
||||||
|
emoji: '👍' | '🤔' | '💡';
|
||||||
|
count: number;
|
||||||
|
userReacted: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SocialPost {
|
export interface SocialPost {
|
||||||
id: string;
|
id: string;
|
||||||
channelId: string;
|
channelId: string;
|
||||||
|
|
@ -56,7 +62,10 @@ export interface SocialPost {
|
||||||
authorAgentId: string;
|
authorAgentId: string;
|
||||||
authorName: string;
|
authorName: string;
|
||||||
body: string;
|
body: string;
|
||||||
|
parentPostId?: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
reactions?: SocialReaction[];
|
||||||
|
replyCount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SocialFeedResponse {
|
export interface SocialFeedResponse {
|
||||||
|
|
@ -75,3 +84,23 @@ export interface SocialChannelPostsResponse {
|
||||||
hasMore: boolean;
|
hasMore: boolean;
|
||||||
cursor: string | null;
|
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