From 8d266ac533cd08368ad25a54981b401fe3e2bdcf Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Wed, 23 Jul 2025 14:39:56 +0200 Subject: [PATCH 01/28] Add backend API for recursive posts --- .../common/src/services/posts/createPost.ts | 70 +++++++ .../common/src/services/posts/getPosts.ts | 177 ++++++++++++++++++ packages/common/src/services/posts/index.ts | 2 + packages/types/src/comments.ts | 19 +- packages/types/src/index.ts | 1 + packages/types/src/posts.ts | 32 ++++ services/api/src/encoders/posts.ts | 6 +- services/api/src/encoders/profiles.ts | 2 + services/api/src/routers/index.ts | 2 + .../organization/createPostInOrganization.ts | 2 +- services/api/src/routers/posts/createPost.ts | 44 +++++ services/api/src/routers/posts/getPosts.ts | 43 +++++ services/api/src/routers/posts/index.ts | 8 + services/db/schema/tables/posts.sql.ts | 21 ++- 14 files changed, 408 insertions(+), 21 deletions(-) create mode 100644 packages/common/src/services/posts/createPost.ts create mode 100644 packages/common/src/services/posts/getPosts.ts create mode 100644 packages/types/src/posts.ts create mode 100644 services/api/src/routers/posts/createPost.ts create mode 100644 services/api/src/routers/posts/getPosts.ts create mode 100644 services/api/src/routers/posts/index.ts diff --git a/packages/common/src/services/posts/createPost.ts b/packages/common/src/services/posts/createPost.ts new file mode 100644 index 000000000..8d6392db5 --- /dev/null +++ b/packages/common/src/services/posts/createPost.ts @@ -0,0 +1,70 @@ +import { db } from '@op/db/client'; +import { posts, postsToOrganizations } from '@op/db/schema'; +import { eq } from 'drizzle-orm'; + +import { CommonError } from '../../utils'; +import { getCurrentProfileId } from '../access'; + +export interface CreatePostInput { + content: string; + parentPostId?: string; // If provided, this becomes a comment/reply + organizationId?: string; // For organization posts +} + +export const createPost = async (input: CreatePostInput) => { + const { content, parentPostId, organizationId } = input; + const profileId = await getCurrentProfileId(); + + try { + // If parentPostId is provided, verify the parent post exists + if (parentPostId) { + const parentPost = await db + .select({ id: posts.id }) + .from(posts) + .where(eq(posts.id, parentPostId)) + .limit(1); + + if (parentPost.length === 0) { + throw new CommonError('Parent post not found'); + } + } + + // Create the post + const [newPost] = await db + .insert(posts) + .values({ + content, + parentPostId: parentPostId || null, + profileId, + }) + .returning(); + + // If organizationId is provided, create the organization association + if (organizationId) { + await db.insert(postsToOrganizations).values({ + postId: newPost.id, + organizationId, + }); + } else if (parentPostId) { + // For comments (posts with parentPostId), inherit organization associations from parent post + const parentOrganizations = await db + .select({ organizationId: postsToOrganizations.organizationId }) + .from(postsToOrganizations) + .where(eq(postsToOrganizations.postId, parentPostId)); + + if (parentOrganizations.length > 0) { + await db.insert(postsToOrganizations).values( + parentOrganizations.map((org) => ({ + postId: newPost.id, + organizationId: org.organizationId, + })) + ); + } + } + + return newPost; + } catch (error) { + console.error('Error creating post:', error); + throw error; + } +}; \ No newline at end of file diff --git a/packages/common/src/services/posts/getPosts.ts b/packages/common/src/services/posts/getPosts.ts new file mode 100644 index 000000000..2f3991d6d --- /dev/null +++ b/packages/common/src/services/posts/getPosts.ts @@ -0,0 +1,177 @@ +import { db } from '@op/db/client'; +import { posts, postsToOrganizations } from '@op/db/schema'; +import { and, desc, eq, isNull } from 'drizzle-orm'; + +import { getCurrentProfileId } from '../access'; +import { getItemsWithReactions } from './listPosts'; + +export interface GetPostsInput { + organizationId?: string; + parentPostId?: string | null; // null for top-level posts, string for child posts, undefined for all + limit?: number; + offset?: number; + includeChildren?: boolean; + maxDepth?: number; +} + +export const getPosts = async (input: GetPostsInput) => { + const { + organizationId, + parentPostId, + limit = 20, + offset = 0, + includeChildren = false, + maxDepth = 3, + } = input; + + try { + // Build where conditions + const conditions = []; + + // Filter by parent post + if (parentPostId === null) { + // Top-level posts only (no parent) - these are "posts" + conditions.push(isNull(posts.parentPostId)); + } else if (parentPostId) { + // Children of specific parent - these are "comments" + conditions.push(eq(posts.parentPostId, parentPostId)); + } + // If parentPostId is undefined, we get all posts regardless of parent + + // Build the query with relations + const query = db.query.posts.findMany({ + where: conditions.length > 0 ? and(...conditions) : undefined, + limit, + offset, + orderBy: [desc(posts.createdAt)], + with: { + profile: { + with: { + avatarImage: true, + }, + }, + attachments: { + with: { + storageObject: true, + }, + }, + reactions: true, + // Recursively include child posts if requested + ...(includeChildren && maxDepth > 0 ? { + childPosts: { + limit: 50, // Reasonable limit for child posts + orderBy: [desc(posts.createdAt)], + with: { + profile: { + with: { + avatarImage: true, + }, + }, + attachments: { + with: { + storageObject: true, + }, + }, + reactions: true, + // One level of nesting for now (can be expanded recursively) + ...(maxDepth > 1 ? { + childPosts: { + limit: 20, + orderBy: [desc(posts.createdAt)], + with: { + profile: { + with: { + avatarImage: true, + }, + }, + reactions: true, + }, + }, + } : {}), + }, + }, + } : {}), + }, + }); + + // If filtering by organization, we need to join through postsToOrganizations + if (organizationId) { + const orgPosts = await db.query.postsToOrganizations.findMany({ + where: eq(postsToOrganizations.organizationId, organizationId), + with: { + post: { + where: conditions.length > 0 ? and(...conditions) : undefined, + with: { + profile: { + with: { + avatarImage: true, + }, + }, + attachments: { + with: { + storageObject: true, + }, + }, + reactions: true, + ...(includeChildren && maxDepth > 0 ? { + childPosts: { + limit: 50, + orderBy: [desc(posts.createdAt)], + with: { + profile: { + with: { + avatarImage: true, + }, + }, + attachments: { + with: { + storageObject: true, + }, + }, + reactions: true, + }, + }, + } : {}), + }, + }, + organization: { + with: { + profile: { + with: { + avatarImage: true, + }, + }, + }, + }, + }, + limit, + offset, + orderBy: [desc(postsToOrganizations.createdAt)], + }); + + // Transform to match expected format and add reaction data + const actorProfileId = await getCurrentProfileId(); + const itemsWithReactions = getItemsWithReactions({ + items: orgPosts, + profileId: actorProfileId, + }); + + return itemsWithReactions; + } + + // Execute query for non-organization posts + const result = await query; + + // Add reaction counts and user reactions + const actorProfileId = await getCurrentProfileId(); + const itemsWithReactions = getItemsWithReactions({ + items: result.map(post => ({ post })), + profileId: actorProfileId, + }); + + return itemsWithReactions.map(item => item.post); + } catch (error) { + console.error('Error fetching posts:', error); + throw error; + } +}; \ No newline at end of file diff --git a/packages/common/src/services/posts/index.ts b/packages/common/src/services/posts/index.ts index 8c14f7240..c7efef45a 100644 --- a/packages/common/src/services/posts/index.ts +++ b/packages/common/src/services/posts/index.ts @@ -1 +1,3 @@ export * from './listPosts'; +export * from './getPosts'; +export * from './createPost'; diff --git a/packages/types/src/comments.ts b/packages/types/src/comments.ts index 4f3d87229..d3fbc6eb5 100644 --- a/packages/types/src/comments.ts +++ b/packages/types/src/comments.ts @@ -1,5 +1,6 @@ import { z } from 'zod'; +// Legacy schema for backward compatibility export const createCommentSchema = z.object({ content: z.string().min(1).max(2000), commentableType: z.string().min(1), @@ -16,6 +17,7 @@ export const deleteCommentSchema = z.object({ id: z.string().uuid(), }); +// Legacy schema export const getCommentsSchema = z.object({ commentableType: z.string().min(1), commentableId: z.string().uuid(), @@ -27,25 +29,8 @@ export const getCommentSchema = z.object({ id: z.string().uuid(), }); -// New schemas for join table operations -export const createCommentForPostSchema = z.object({ - content: z.string().min(1).max(2000), - postId: z.string().uuid(), - parentCommentId: z.string().uuid().optional(), -}); - - -export const getCommentsForPostSchema = z.object({ - postId: z.string().uuid(), - limit: z.number().min(1).max(100).default(20), - offset: z.number().min(0).default(0), -}); - - export type CreateCommentInput = z.infer; export type UpdateCommentInput = z.infer; export type DeleteCommentInput = z.infer; export type GetCommentsInput = z.infer; export type GetCommentInput = z.infer; -export type CreateCommentForPostInput = z.infer; -export type GetCommentsForPostInput = z.infer; \ No newline at end of file diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 4b6cb36a5..76d808771 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -1,3 +1,4 @@ export * from './relationships'; export * from './reactions'; export * from './comments'; +export * from './posts'; diff --git a/packages/types/src/posts.ts b/packages/types/src/posts.ts new file mode 100644 index 000000000..02e0616a6 --- /dev/null +++ b/packages/types/src/posts.ts @@ -0,0 +1,32 @@ +import { z } from 'zod'; + +// Unified post creation schema +export const createPostSchema = z.object({ + content: z.string().min(1).max(10000), + parentPostId: z.string().uuid().optional(), // If provided, this becomes a comment/reply + organizationId: z.string().uuid().optional(), // For organization posts +}); + +// Unified post fetching schema +export const getPostsSchema = z.object({ + organizationId: z.string().uuid().optional(), + parentPostId: z.string().uuid().optional().nullable(), // null for top-level posts, string for comments of that post, undefined for all levels + limit: z.number().min(1).max(100).default(20), + offset: z.number().min(0).default(0), + includeChildren: z.boolean().default(false), + maxDepth: z.number().min(1).max(5).default(3), +}); + +export const updatePostSchema = z.object({ + id: z.string().uuid(), + content: z.string().min(1).max(10000), +}); + +export const deletePostSchema = z.object({ + id: z.string().uuid(), +}); + +export type CreatePostInput = z.infer; +export type GetPostsInput = z.infer; +export type UpdatePostInput = z.infer; +export type DeletePostInput = z.infer; \ No newline at end of file diff --git a/services/api/src/encoders/posts.ts b/services/api/src/encoders/posts.ts index c8be7f6c6..f7c99d164 100644 --- a/services/api/src/encoders/posts.ts +++ b/services/api/src/encoders/posts.ts @@ -3,17 +3,21 @@ import { createSelectSchema } from 'drizzle-zod'; import { z } from 'zod'; import { organizationsWithProfileEncoder } from './organizations'; +import { profileWithAvatarEncoder } from './profiles'; import { storageItemEncoder } from './storageItem'; export const postAttachmentEncoder = createSelectSchema(attachments).extend({ storageObject: storageItemEncoder, }); -export const postsEncoder = createSelectSchema(posts) +export const postsEncoder: z.ZodType = createSelectSchema(posts) .extend({ attachments: z.array(postAttachmentEncoder).nullish(), reactionCounts: z.record(z.string(), z.number()), userReaction: z.string().nullish(), + profile: profileWithAvatarEncoder.nullish(), + childPosts: z.array(z.lazy(() => postsEncoder)).nullish(), + parentPost: z.lazy(() => postsEncoder).nullish(), }) .strip(); diff --git a/services/api/src/encoders/profiles.ts b/services/api/src/encoders/profiles.ts index 1a4fabd2d..c699ca573 100644 --- a/services/api/src/encoders/profiles.ts +++ b/services/api/src/encoders/profiles.ts @@ -33,4 +33,6 @@ export const profileEncoder = baseProfileEncoder.extend({ .nullish(), }); +export const profileWithAvatarEncoder = baseProfileEncoder; + export type Profile = z.infer; diff --git a/services/api/src/routers/index.ts b/services/api/src/routers/index.ts index c73238658..f96bca390 100644 --- a/services/api/src/routers/index.ts +++ b/services/api/src/routers/index.ts @@ -5,6 +5,7 @@ import { contentRouter } from './content'; import individualRouter from './individual'; import llmRouter from './llm'; import { organizationRouter } from './organization'; +import { postsRouter } from './posts'; import profileRouter from './profile'; import { taxonomyRouter } from './taxonomy'; @@ -17,6 +18,7 @@ export const appRouter = router({ taxonomy: taxonomyRouter, content: contentRouter, comments: commentsRouter, + posts: postsRouter, }); export type AppRouter = typeof appRouter; diff --git a/services/api/src/routers/organization/createPostInOrganization.ts b/services/api/src/routers/organization/createPostInOrganization.ts index 60bc1171c..f99e388da 100644 --- a/services/api/src/routers/organization/createPostInOrganization.ts +++ b/services/api/src/routers/organization/createPostInOrganization.ts @@ -23,7 +23,7 @@ const meta: OpenApiMeta = { }, }; -const outputSchema = postsEncoder.strip(); +const outputSchema = postsEncoder; export const createPostInOrganization = router({ createPost: loggedProcedure diff --git a/services/api/src/routers/posts/createPost.ts b/services/api/src/routers/posts/createPost.ts new file mode 100644 index 000000000..f0fb6c4e3 --- /dev/null +++ b/services/api/src/routers/posts/createPost.ts @@ -0,0 +1,44 @@ +import { createPost as createPostService } from '@op/common'; +import { createPostSchema } from '@op/types'; +import { TRPCError } from '@trpc/server'; +import type { OpenApiMeta } from 'trpc-to-openapi'; + +import { postsEncoder } from '../../encoders'; +import withAuthenticated from '../../middlewares/withAuthenticated'; +import withRateLimited from '../../middlewares/withRateLimited'; +import { loggedProcedure, router } from '../../trpcFactory'; + +const meta: OpenApiMeta = { + openapi: { + enabled: true, + method: 'POST', + path: '/posts', + protect: true, + tags: ['posts'], + summary: 'Create a post (or comment)', + }, +}; + +const outputSchema = postsEncoder; + +export const createPost = router({ + createPost: loggedProcedure + .use(withRateLimited({ windowSize: 10, maxRequests: 10 })) + .use(withAuthenticated) + .meta(meta) + .input(createPostSchema) + .output(outputSchema) + .mutation(async ({ input }) => { + try { + const post = await createPostService(input); + const output = outputSchema.parse(post); + return output; + } catch (error) { + console.log('ERROR', error); + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Something went wrong when creating post', + }); + } + }), +}); \ No newline at end of file diff --git a/services/api/src/routers/posts/getPosts.ts b/services/api/src/routers/posts/getPosts.ts new file mode 100644 index 000000000..55b8e57f7 --- /dev/null +++ b/services/api/src/routers/posts/getPosts.ts @@ -0,0 +1,43 @@ +import { getPosts as getPostsService } from '@op/common'; +import { getPostsSchema } from '@op/types'; +import { TRPCError } from '@trpc/server'; +import type { OpenApiMeta } from 'trpc-to-openapi'; +import { z } from 'zod'; + +import { postsEncoder } from '../../encoders'; +import withAuthenticated from '../../middlewares/withAuthenticated'; +import { loggedProcedure, router } from '../../trpcFactory'; + +const meta: OpenApiMeta = { + openapi: { + enabled: true, + method: 'GET', + path: '/posts', + protect: true, + tags: ['posts'], + summary: 'Get posts (with optional children/comments)', + }, +}; + +const outputSchema = z.array(postsEncoder); + +export const getPosts = router({ + getPosts: loggedProcedure + .use(withAuthenticated) + .meta(meta) + .input(getPostsSchema) + .output(outputSchema) + .query(async ({ input }) => { + try { + const posts = await getPostsService(input); + const output = outputSchema.parse(posts); + return output; + } catch (error) { + console.log('ERROR', error); + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Something went wrong when fetching posts', + }); + } + }), +}); \ No newline at end of file diff --git a/services/api/src/routers/posts/index.ts b/services/api/src/routers/posts/index.ts new file mode 100644 index 000000000..b85a4ca24 --- /dev/null +++ b/services/api/src/routers/posts/index.ts @@ -0,0 +1,8 @@ +import { mergeRouters } from '../../trpcFactory'; +import { createPost } from './createPost'; +import { getPosts } from './getPosts'; + +export const postsRouter = mergeRouters( + createPost, + getPosts, +); \ No newline at end of file diff --git a/services/db/schema/tables/posts.sql.ts b/services/db/schema/tables/posts.sql.ts index d01742c37..a6a6bca52 100644 --- a/services/db/schema/tables/posts.sql.ts +++ b/services/db/schema/tables/posts.sql.ts @@ -6,12 +6,17 @@ import { attachments } from './attachments.sql'; import { comments } from './comments.sql'; import { organizations } from './organizations.sql'; import { postReactions } from './postReactions.sql'; +import { profiles } from './profiles.sql'; -export const posts = pgTable( +export const posts: any = pgTable( 'posts', { id: autoId().primaryKey(), content: text().notNull(), + parentPostId: uuid().references((): any => posts.id, { + onDelete: 'cascade', + }), + profileId: uuid().references(() => profiles.id, { onDelete: 'cascade' }), ...timestamps, }, (table) => [...serviceRolePolicies, index().on(table.id).concurrently()], @@ -38,11 +43,23 @@ export const postsToOrganizations = pgTable( ], ); -export const postsRelations = relations(posts, ({ many }) => ({ +export const postsRelations = relations(posts, ({ one, many }) => ({ organization: many(organizations), attachments: many(attachments), reactions: many(postReactions), comments: many(comments), + profile: one(profiles, { + fields: [posts.profileId], + references: [profiles.id], + }), + parentPost: one(posts, { + fields: [posts.parentPostId], + references: [posts.id], + relationName: 'PostToParent', + }), + childPosts: many(posts, { + relationName: 'PostToParent', + }), })); export const postsToOrganizationsRelations = relations( From 3e5eed239d764e4996386a6204762e9b16e9a437 Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Wed, 23 Jul 2025 15:19:36 +0200 Subject: [PATCH 02/28] Adding in Comment Modal with component styles --- .../app/src/components/CommentModal/index.tsx | 146 ++++++++++++++++++ .../common/src/services/posts/createPost.ts | 10 +- .../common/src/services/posts/listPosts.ts | 8 +- .../listRelatedOrganizationPosts.ts | 14 +- 4 files changed, 170 insertions(+), 8 deletions(-) create mode 100644 apps/app/src/components/CommentModal/index.tsx diff --git a/apps/app/src/components/CommentModal/index.tsx b/apps/app/src/components/CommentModal/index.tsx new file mode 100644 index 000000000..7160e20b2 --- /dev/null +++ b/apps/app/src/components/CommentModal/index.tsx @@ -0,0 +1,146 @@ +'use client'; + +import { trpc } from '@op/api/client'; +import type { Post } from '@op/api/encoders'; +import { Button } from '@op/ui/Button'; +import { Modal, ModalHeader } from '@op/ui/Modal'; +import { useEffect } from 'react'; +import { LuX } from 'react-icons/lu'; + +import { PostFeed } from '../PostFeed'; +import { PostUpdate } from '../PostUpdate'; + +interface CommentModalProps { + post: Post; + isOpen: boolean; + onClose: () => void; +} + +export function CommentModal({ post, isOpen, onClose }: CommentModalProps) { + const utils = trpc.useUtils(); + + const { data: commentsData, isLoading } = trpc.posts.getPosts.useQuery( + { + parentPostId: post.id ?? null, // Get comments (child posts) of this post + limit: 50, + offset: 0, + includeChildren: false, + }, + { enabled: isOpen }, + ); + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape' && isOpen) { + event.preventDefault(); + onClose(); + } + }; + + if (isOpen) { + document.addEventListener('keydown', handleKeyDown); + } + + return () => { + document.removeEventListener('keydown', handleKeyDown); + }; + }, [isOpen, onClose]); + + const handleCommentSuccess = () => { + utils.posts.getPosts.invalidate({ + parentPostId: post.id ?? null, // Invalidate comments for this post + }); + }; + + // Transform comments data to match PostFeed expected format + const comments = + commentsData?.map((comment) => ({ + createdAt: comment.createdAt, + updatedAt: comment.updatedAt, + deletedAt: null, + postId: comment.id, + organizationId: '', // Not needed for comments + post: comment, + organization: null, // Comments don't need organization context in the modal + })) || []; + + return ( + + + {/* Desktop header */} +
+ Comments + +
+ + {/* Mobile header */} +
+ +

Comments

+
{/* Spacer for center alignment */} +
+ + +
+ {/* Original Post Display */} +
+ +
+ + {/* Comments Display */} +
+ {isLoading ? ( +
+ Loading comments... +
+ ) : comments.length > 0 ? ( + + ) : ( +
+ No comments yet. Be the first to comment! +
+ )} +
+ + {/* Comment Input using PostUpdate */} +
+ +
+
+ + ); +} diff --git a/packages/common/src/services/posts/createPost.ts b/packages/common/src/services/posts/createPost.ts index 8d6392db5..da09df35a 100644 --- a/packages/common/src/services/posts/createPost.ts +++ b/packages/common/src/services/posts/createPost.ts @@ -57,14 +57,18 @@ export const createPost = async (input: CreatePostInput) => { parentOrganizations.map((org) => ({ postId: newPost.id, organizationId: org.organizationId, - })) + })), ); } } - return newPost; + return { + ...newPost, + reactionCounts: {}, + userReactions: [], + }; } catch (error) { console.error('Error creating post:', error); throw error; } -}; \ No newline at end of file +}; diff --git a/packages/common/src/services/posts/listPosts.ts b/packages/common/src/services/posts/listPosts.ts index 61006cb37..796b78154 100644 --- a/packages/common/src/services/posts/listPosts.ts +++ b/packages/common/src/services/posts/listPosts.ts @@ -68,6 +68,7 @@ export const listPosts = async ({ : (table, { eq }) => eq(table.organizationId, org.id), with: { post: { + where: (table, { isNull }) => isNull(table.parentPostId), // Only show top-level posts with: { attachments: { with: { @@ -91,8 +92,11 @@ export const listPosts = async ({ limit: limit + 1, // Fetch one extra to check hasMore }); - const hasMore = result.length > limit; - const items = hasMore ? result.slice(0, limit) : result; + // Filter out any items where post is null (due to parentPostId filtering) + const filteredResult = result.filter(item => item.post !== null); + + const hasMore = filteredResult.length > limit; + const items = hasMore ? filteredResult.slice(0, limit) : filteredResult; const lastItem = items[items.length - 1]; const nextCursor = hasMore && lastItem && lastItem.createdAt diff --git a/services/api/src/routers/organization/listRelatedOrganizationPosts.ts b/services/api/src/routers/organization/listRelatedOrganizationPosts.ts index 03ac354a9..c2eeddf50 100644 --- a/services/api/src/routers/organization/listRelatedOrganizationPosts.ts +++ b/services/api/src/routers/organization/listRelatedOrganizationPosts.ts @@ -94,6 +94,7 @@ export const listRelatedOrganizationPostsRouter = router({ where: cursorCondition, with: { post: { + where: (table, { isNull }) => isNull(table.parentPostId), // Only show top-level posts with: { attachments: { with: { @@ -119,8 +120,11 @@ export const listRelatedOrganizationPostsRouter = router({ getCurrentProfileId(), ]); - const hasMore = result.length > limit; - const items = hasMore ? result.slice(0, limit) : result; + // Filter out any items where post is null (due to parentPostId filtering) + const filteredResult = result.filter(item => item.post !== null); + + const hasMore = filteredResult.length > limit; + const items = hasMore ? filteredResult.slice(0, limit) : filteredResult; const lastItem = items[items.length - 1]; const nextCursor = hasMore && lastItem && lastItem.createdAt @@ -164,6 +168,7 @@ export const listRelatedOrganizationPostsRouter = router({ where: () => inArray(postsToOrganizations.organizationId, orgIds), with: { post: { + where: (table, { isNull }) => isNull(table.parentPostId), // Only show top-level posts with: { attachments: { with: { @@ -185,7 +190,10 @@ export const listRelatedOrganizationPostsRouter = router({ orderBy: (table, { desc }) => desc(table.createdAt), }); - return result.map((postToOrg) => ({ + // Filter out any items where post is null (due to parentPostId filtering) + const filteredResult = result.filter(item => item.post !== null); + + return filteredResult.map((postToOrg) => ({ ...postToOrg, organization: organizationsWithProfileEncoder.parse( postToOrg.organization, From 33d6067c255eeb4203190d1a99a82c2580737bc6 Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Wed, 23 Jul 2025 15:22:26 +0200 Subject: [PATCH 03/28] Rename to DiscussionModal --- .../{CommentModal => DiscussionModal}/index.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) rename apps/app/src/components/{CommentModal => DiscussionModal}/index.tsx (94%) diff --git a/apps/app/src/components/CommentModal/index.tsx b/apps/app/src/components/DiscussionModal/index.tsx similarity index 94% rename from apps/app/src/components/CommentModal/index.tsx rename to apps/app/src/components/DiscussionModal/index.tsx index 7160e20b2..ee947da39 100644 --- a/apps/app/src/components/CommentModal/index.tsx +++ b/apps/app/src/components/DiscussionModal/index.tsx @@ -10,13 +10,13 @@ import { LuX } from 'react-icons/lu'; import { PostFeed } from '../PostFeed'; import { PostUpdate } from '../PostUpdate'; -interface CommentModalProps { +interface DiscussionModalProps { post: Post; isOpen: boolean; onClose: () => void; } -export function CommentModal({ post, isOpen, onClose }: CommentModalProps) { +export function DiscussionModal({ post, isOpen, onClose }: DiscussionModalProps) { const utils = trpc.useUtils(); const { data: commentsData, isLoading } = trpc.posts.getPosts.useQuery( @@ -74,7 +74,7 @@ export function CommentModal({ post, isOpen, onClose }: CommentModalProps) { {/* Desktop header */}
- Comments + Discussion
@@ -87,7 +87,7 @@ export function CommentModal({ post, isOpen, onClose }: CommentModalProps) { > Close -

Comments

+

Discussion

{/* Spacer for center alignment */}
@@ -116,7 +116,7 @@ export function CommentModal({ post, isOpen, onClose }: CommentModalProps) {
{isLoading ? (
- Loading comments... + Loading discussion...
) : comments.length > 0 ? ( ); -} +} \ No newline at end of file From 8a83ed16625bc30197b844c9a85cb0564bb4af13 Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Wed, 23 Jul 2025 15:39:02 +0200 Subject: [PATCH 04/28] Update styles on DiscussionModal --- .../src/components/DiscussionModal/index.tsx | 32 ++++++++++------- apps/app/src/components/PostUpdate/index.tsx | 34 +++++++++++++++---- .../common/src/services/posts/listPosts.ts | 4 +-- 3 files changed, 50 insertions(+), 20 deletions(-) diff --git a/apps/app/src/components/DiscussionModal/index.tsx b/apps/app/src/components/DiscussionModal/index.tsx index ee947da39..14ae48474 100644 --- a/apps/app/src/components/DiscussionModal/index.tsx +++ b/apps/app/src/components/DiscussionModal/index.tsx @@ -1,9 +1,11 @@ 'use client'; +import { useUser } from '@/utils/UserProvider'; import { trpc } from '@op/api/client'; import type { Post } from '@op/api/encoders'; import { Button } from '@op/ui/Button'; -import { Modal, ModalHeader } from '@op/ui/Modal'; +import { Modal, ModalFooter, ModalHeader } from '@op/ui/Modal'; +import { Surface } from '@op/ui/Surface'; import { useEffect } from 'react'; import { LuX } from 'react-icons/lu'; @@ -16,8 +18,13 @@ interface DiscussionModalProps { onClose: () => void; } -export function DiscussionModal({ post, isOpen, onClose }: DiscussionModalProps) { +export function DiscussionModal({ + post, + isOpen, + onClose, +}: DiscussionModalProps) { const utils = trpc.useUtils(); + const { user } = useUser(); const { data: commentsData, isLoading } = trpc.posts.getPosts.useQuery( { @@ -92,7 +99,7 @@ export function DiscussionModal({ post, isOpen, onClose }: DiscussionModalProps)
-
+
{/* Original Post Display */}
{/* Comment Input using PostUpdate */} -
- -
+ + + + +
); -} \ No newline at end of file +} diff --git a/apps/app/src/components/PostUpdate/index.tsx b/apps/app/src/components/PostUpdate/index.tsx index aef022957..d22073d60 100644 --- a/apps/app/src/components/PostUpdate/index.tsx +++ b/apps/app/src/components/PostUpdate/index.tsx @@ -48,9 +48,15 @@ const TextCounter = ({ text, max }: { text: string; max: number }) => { const PostUpdateWithUser = ({ organization, className, + parentPostId, + placeholder, + onSuccess, }: { organization: Organization; className?: string; + parentPostId?: string; // If provided, this becomes a comment + placeholder?: string; + onSuccess?: () => void; }) => { const [content, setContent] = useState(''); const [detectedUrls, setDetectedUrls] = useState([]); @@ -75,7 +81,7 @@ const PostUpdateWithUser = ({ maxFiles: 1, }); - const createPost = trpc.organization.createPost.useMutation({ + const createPost = trpc.posts.createPost.useMutation({ onError: (err) => { const errorInfo = analyzeError(err); @@ -101,6 +107,11 @@ const PostUpdateWithUser = ({ setDetectedUrls([]); fileUpload.clearFiles(); setLastFailedPost(null); + + // Call onSuccess callback if provided (for comments) + if (onSuccess) { + onSuccess(); + } }, onSettled: () => { void utils.organization.listPosts.invalidate(); @@ -112,9 +123,10 @@ const PostUpdateWithUser = ({ const retryFailedPost = () => { if (lastFailedPost) { createPost.mutate({ - id: organization.id, content: lastFailedPost.content, - attachmentIds: lastFailedPost.attachmentIds, + organizationId: organization.id, + parentPostId, + // TODO: Handle attachmentIds in the new API }); } }; @@ -137,9 +149,10 @@ const PostUpdateWithUser = ({ } createPost.mutate({ - id: organization.id, content: content.trim() || '', - attachmentIds: fileUpload.getUploadedAttachmentIds(), + organizationId: organization.id, + parentPostId, + // TODO: Handle attachmentIds in the new API }); } }; @@ -183,7 +196,7 @@ const PostUpdateWithUser = ({ className="size-full h-6 overflow-y-hidden" variant="borderless" ref={textareaRef as RefObject} - placeholder={`Post an update…`} + placeholder={placeholder || `Post an update…`} value={content} onChange={(e) => handleContentChange(e.target.value ?? '')} onKeyDown={handleKeyDown} @@ -310,9 +323,15 @@ const PostUpdateWithUser = ({ export const PostUpdate = ({ organization, className, + parentPostId, + placeholder, + onSuccess, }: { organization?: Organization; className?: string; + parentPostId?: string; + placeholder?: string; + onSuccess?: () => void; }) => { const { user } = useUser(); const currentOrg = user?.currentOrganization; @@ -328,6 +347,9 @@ export const PostUpdate = ({ ); }; diff --git a/packages/common/src/services/posts/listPosts.ts b/packages/common/src/services/posts/listPosts.ts index 796b78154..606d995eb 100644 --- a/packages/common/src/services/posts/listPosts.ts +++ b/packages/common/src/services/posts/listPosts.ts @@ -93,8 +93,8 @@ export const listPosts = async ({ }); // Filter out any items where post is null (due to parentPostId filtering) - const filteredResult = result.filter(item => item.post !== null); - + const filteredResult = result.filter((item) => item.post !== null); + const hasMore = filteredResult.length > limit; const items = hasMore ? filteredResult.slice(0, limit) : filteredResult; const lastItem = items[items.length - 1]; From caf0426fd39307e3904a430a917bfe335f899d7d Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Wed, 23 Jul 2025 16:47:14 +0200 Subject: [PATCH 05/28] Allow for passing of org --- .../src/components/DiscussionModal/index.tsx | 28 +- apps/app/src/components/PostFeed/index.tsx | 515 +++++++++++++----- 2 files changed, 386 insertions(+), 157 deletions(-) diff --git a/apps/app/src/components/DiscussionModal/index.tsx b/apps/app/src/components/DiscussionModal/index.tsx index 14ae48474..58ba74831 100644 --- a/apps/app/src/components/DiscussionModal/index.tsx +++ b/apps/app/src/components/DiscussionModal/index.tsx @@ -2,7 +2,7 @@ import { useUser } from '@/utils/UserProvider'; import { trpc } from '@op/api/client'; -import type { Post } from '@op/api/encoders'; +import type { Organization, Post } from '@op/api/encoders'; import { Button } from '@op/ui/Button'; import { Modal, ModalFooter, ModalHeader } from '@op/ui/Modal'; import { Surface } from '@op/ui/Surface'; @@ -14,12 +14,14 @@ import { PostUpdate } from '../PostUpdate'; interface DiscussionModalProps { post: Post; + organization?: Organization | null; isOpen: boolean; onClose: () => void; } export function DiscussionModal({ post, + organization, isOpen, onClose, }: DiscussionModalProps) { @@ -59,6 +61,9 @@ export function DiscussionModal({ }); }; + // Get the post author's name for the header + const authorName = post?.profile?.name || 'Unknown'; + // Transform comments data to match PostFeed expected format const comments = commentsData?.map((comment) => ({ @@ -81,7 +86,7 @@ export function DiscussionModal({ {/* Desktop header */}
- Discussion + {organization?.profile.name}'s Post
@@ -94,14 +99,14 @@ export function DiscussionModal({ > Close -

Discussion

+

{authorName}'s Post

{/* Spacer for center alignment */}
-
- {/* Original Post Display */} -
+
+
+ {/* Original Post Display */} -
- - {/* Comments Display */} -
+ {/* Comments Display */} {isLoading ? (
Loading discussion... diff --git a/apps/app/src/components/PostFeed/index.tsx b/apps/app/src/components/PostFeed/index.tsx index 5b9248de1..aa64bd2e5 100644 --- a/apps/app/src/components/PostFeed/index.tsx +++ b/apps/app/src/components/PostFeed/index.tsx @@ -4,7 +4,12 @@ import { getPublicUrl } from '@/utils'; import { OrganizationUser } from '@/utils/UserProvider'; import { detectLinks, linkifyText } from '@/utils/linkDetection'; import { trpc } from '@op/api/client'; -import type { PostToOrganization } from '@op/api/encoders'; +import type { + Organization, + Post, + PostToOrganization, + Profile, +} from '@op/api/encoders'; import { REACTION_OPTIONS } from '@op/types'; import { AvatarSkeleton } from '@op/ui/Avatar'; import { Button } from '@op/ui/Button'; @@ -17,12 +22,12 @@ import { Skeleton, SkeletonLine } from '@op/ui/Skeleton'; import { toast } from '@op/ui/Toast'; import { cn } from '@op/ui/utils'; import Image from 'next/image'; -import { useFeatureFlagEnabled } from 'posthog-js/react'; -import { Fragment, ReactNode } from 'react'; -import { LuEllipsis, LuLeaf } from 'react-icons/lu'; +import { Fragment, ReactNode, useState } from 'react'; +import { LuEllipsis, LuLeaf, LuMessageCircle } from 'react-icons/lu'; import { Link } from '@/lib/i18n'; +import { DiscussionModal } from '../DiscussionModal'; import { LinkPreview } from '../LinkPreview'; import { OrganizationAvatar } from '../OrganizationAvatar'; import { DeletePost } from './DeletePost'; @@ -127,7 +132,7 @@ export const FeedMain = ({ return (
{ + if (!displayName) return null; + + if (withLinks && !isComment) { + return {displayName}; + } + + return <>{displayName}; +}; + +const PostTimestamp = ({ createdAt }: { createdAt?: Date | string }) => { + if (!createdAt) return null; + + return ( + + {formatRelativeTime(createdAt)} + + ); +}; + +const PostContent = ({ content }: { content?: string }) => { + if (!content) return null; + + return <>{linkifyText(content)}; +}; + +const PostAttachments = ({ attachments }: { attachments?: any[] }) => { + if (!attachments) return null; + + return ( + <> + {attachments.map(({ fileName, storageObject }: any) => { + const { mimetype, size } = storageObject.metadata; + + return ( + + + + ); + })} + + ); +}; + +const AttachmentImage = ({ + mimetype, + fileName, + storageObjectName, +}: { + mimetype: string; + fileName: string; + storageObjectName: string; +}) => { + if (!mimetype.startsWith('image/')) return null; + + return ( +
+ {fileName} +
+ ); +}; + +const PostUrls = ({ urls }: { urls: string[] }) => { + if (urls.length === 0) return null; + + return ( +
+ {urls.map((url) => ( + + ))} +
+ ); +}; + +const PostReactions = ({ + post, + onReactionClick, +}: { + post: any; + onReactionClick: (postId: string, emoji: string) => void; +}) => { + if (!post?.id) return null; + + const reactions = post.reactionCounts + ? Object.entries(post.reactionCounts).map(([reactionType, count]) => { + const reactionOption = REACTION_OPTIONS.find( + (option) => option.key === reactionType, + ); + const emoji = reactionOption?.emoji || reactionType; + + return { + emoji, + count: count as number, + isActive: post.userReaction === reactionType, + }; + }) + : []; + + return ( + onReactionClick(post.id!, emoji)} + onAddReaction={(emoji) => onReactionClick(post.id!, emoji)} + /> + ); +}; + +const PostCommentButton = ({ + post, + isComment, + onCommentClick, +}: { + post: any; + isComment: boolean; + onCommentClick: () => void; +}) => { + if (!post?.id || isComment) return null; + + return ( + + ); +}; + +const PostMenu = ({ + organization, + post, + user, + isComment, +}: { + organization: any; + post: any; + user?: OrganizationUser; + isComment: boolean; +}) => { + const canShowMenu = + (organization?.id === user?.currentOrganization?.id || + (isComment && post?.profile?.id === user?.profile?.id)) && + post?.id; + + if (!canShowMenu) return null; + + return ( + + + + + + + ); +}; + +const PostMenuContent = ({ + postId, + organizationId, + canDelete, +}: { + postId: string; + organizationId: string; + canDelete: boolean; +}) => { + if (!canDelete) return null; + + return ; +}; + +const EmptyPostsState = () => ( + + + +
+ +
+ {'No posts yet.'} +
+
+
+); + +const PostsList = ({ + posts, + user, + withLinks, + onReactionClick, + onCommentClick, +}: { + posts: Array; + user?: OrganizationUser; + withLinks: boolean; + onReactionClick: (postId: string, emoji: string) => void; + onCommentClick: (post: Post, org?: Organization | null) => void; +}) => ( + <> + {posts.map(({ organization, post }, i) => { + const { urls } = detectLinks(post?.content); + + // For comments (posts without organization), show the post author + const isComment = !organization; + const displayName = isComment + ? post?.profile?.name + : organization?.profile.name; + const displaySlug = isComment + ? post?.profile?.slug + : organization?.profile.slug; + const avatarData = isComment + ? { profile: post?.profile as Profile } + : organization; + + if (organization && post.content === 'happy wednesday everyone!') { + console.log('ORGPOST', organization, post, displayName); + } + + return ( + + + + + +
+ + + + +
+ +
+ + + + +
+ + onCommentClick(post, organization)} + /> +
+
+
+
+
+
+ ); + })} + +); + +const DiscussionModalContainer = ({ + discussionModal, + onClose, +}: { + discussionModal: { isOpen: boolean; post: Post; org?: Organization | null }; + onClose: () => void; +}) => { + if (!discussionModal.isOpen || !discussionModal.post) { + return null; + } + + return ( + + ); +}; + export const PostFeed = ({ posts, user, @@ -152,8 +488,16 @@ export const PostFeed = ({ slug?: string; limit?: number; }) => { - const reactionsEnabled = useFeatureFlagEnabled('reactions'); const utils = trpc.useUtils(); + const [discussionModal, setDiscussionModal] = useState<{ + isOpen: boolean; + post: any; + org?: Organization | null; + }>({ + isOpen: false, + post: null, + org: null, + }); const toggleReaction = trpc.organization.toggleReaction.useMutation({ onMutate: async ({ postId, reactionType }) => { @@ -287,149 +631,32 @@ export const PostFeed = ({ toggleReaction.mutate({ postId, reactionType }); }; + const handleCommentClick = (post: Post, org?: Organization) => { + setDiscussionModal({ isOpen: true, post, org }); + }; + + const handleModalClose = () => { + setDiscussionModal({ isOpen: false, post: null, org: null }); + }; + return (
{posts.length > 0 ? ( - posts.map(({ organization, post }, i) => { - const { urls } = detectLinks(post?.content); - - return ( - - - - - -
- - {withLinks ? ( - - {organization?.profile.name} - - ) : ( - organization?.profile.name - )} - - {post?.createdAt ? ( - - {formatRelativeTime(post?.createdAt)} - - ) : null} -
- {organization?.id === user?.currentOrganization?.id && - post?.id && ( - - - - {post?.id && organization?.id ? ( - - ) : null} - - - )} -
- - {post?.content ? linkifyText(post.content) : null} - {post.attachments - ? post.attachments.map(({ fileName, storageObject }) => { - const { mimetype, size } = storageObject.metadata; - - return ( - - {mimetype.startsWith('image/') ? ( -
- {fileName} -
- ) : null} -
- ); - }) - : null} - {urls.length > 0 && ( -
- {urls.map((url) => ( - - ))} -
- )} - {reactionsEnabled && post?.id && ( - { - // Convert reaction type to emoji - const reactionOption = REACTION_OPTIONS.find( - (option) => option.key === reactionType, - ); - const emoji = - reactionOption?.emoji || reactionType; - - return { - emoji, - count, - isActive: - post.userReaction === reactionType, - }; - }, - ) - : [] - } - reactionOptions={REACTION_OPTIONS} - onReactionClick={(emoji) => { - handleReactionClick(post.id!, emoji); - }} - onAddReaction={(emoji) => { - handleReactionClick(post.id!, emoji); - }} - /> - )} -
-
-
-
-
- ); - }) + ) : ( - - - -
- -
- {'No posts yet.'} -
-
-
+ )} + +
); }; From 64a0d49fcbdb9d7f2922b3c1e9f0cfde45ffc70e Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Wed, 23 Jul 2025 17:03:20 +0200 Subject: [PATCH 06/28] Add CommentButton --- packages/ui/src/components/CommentButton.tsx | 71 ++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 packages/ui/src/components/CommentButton.tsx diff --git a/packages/ui/src/components/CommentButton.tsx b/packages/ui/src/components/CommentButton.tsx new file mode 100644 index 000000000..143f41b00 --- /dev/null +++ b/packages/ui/src/components/CommentButton.tsx @@ -0,0 +1,71 @@ +'use client'; + +import { Button as RACButton } from 'react-aria-components'; +import { tv } from 'tailwind-variants'; +import type { VariantProps } from 'tailwind-variants'; + +const commentButtonStyle = tv({ + base: 'flex h-8 items-center justify-center gap-1 px-2 py-1 rounded text-xs font-normal leading-[1.5] text-nowrap outline-none transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#0046c2]', + variants: { + variant: { + default: 'bg-neutral-offWhite text-neutral-gray4', + hover: 'bg-neutral-gray1 text-neutral-charcoal', + pressed: 'bg-neutral-gray2 text-neutral-black', + focus: 'bg-neutral-offWhite text-neutral-gray4 outline outline-2 outline-[#0046c2]', + }, + }, + defaultVariants: { + variant: 'default', + }, +}); + +const iconStyle = tv({ + base: 'w-4 h-4 shrink-0', +}); + +// Message Circle Icon SVG +const MessageCircleIcon = ({ className }: { className?: string }) => ( + + + +); + +type CommentButtonVariants = VariantProps; + +export interface CommentButtonProps + extends Omit, 'children'>, + CommentButtonVariants { + count?: number; + className?: string; +} + +export const CommentButton = ({ + variant = 'default', + count = 0, + className, + ...props +}: CommentButtonProps) => { + return ( + + + {count} comments + + ); +}; \ No newline at end of file From b00f6a1e4dc6313d91e0e1cfbcf196e03912e348 Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Wed, 23 Jul 2025 17:03:36 +0200 Subject: [PATCH 07/28] CommentButton export --- packages/ui/src/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 2447752b2..4c81a0f9d 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -15,6 +15,7 @@ export * from './components/ColorThumb'; export * from './components/ColorWheel'; export * from './components/ComboBox'; export * from './components/Command'; +export * from './components/CommentButton'; export * from './components/DateField'; export * from './components/DatePicker'; export * from './components/DateRangePicker'; From 60e8fb041ea75c9c2b7e57cc4bb54a96d56fa91f Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Wed, 23 Jul 2025 17:18:03 +0200 Subject: [PATCH 08/28] Add CommentButton to PostFeed --- apps/app/src/components/PostFeed/index.tsx | 14 +++++--------- packages/ui/package.json | 1 + 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/apps/app/src/components/PostFeed/index.tsx b/apps/app/src/components/PostFeed/index.tsx index aa64bd2e5..626d48ee7 100644 --- a/apps/app/src/components/PostFeed/index.tsx +++ b/apps/app/src/components/PostFeed/index.tsx @@ -13,6 +13,7 @@ import type { import { REACTION_OPTIONS } from '@op/types'; import { AvatarSkeleton } from '@op/ui/Avatar'; import { Button } from '@op/ui/Button'; +import { CommentButton } from '@op/ui/CommentButton'; import { Header3 } from '@op/ui/Header'; import { MediaDisplay } from '@op/ui/MediaDisplay'; import { MenuTrigger } from '@op/ui/Menu'; @@ -23,7 +24,7 @@ import { toast } from '@op/ui/Toast'; import { cn } from '@op/ui/utils'; import Image from 'next/image'; import { Fragment, ReactNode, useState } from 'react'; -import { LuEllipsis, LuLeaf, LuMessageCircle } from 'react-icons/lu'; +import { LuEllipsis, LuLeaf } from 'react-icons/lu'; import { Link } from '@/lib/i18n'; @@ -287,15 +288,10 @@ const PostCommentButton = ({ if (!post?.id || isComment) return null; return ( - + /> ); }; diff --git a/packages/ui/package.json b/packages/ui/package.json index b0e030e7e..c6e7a6293 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -17,6 +17,7 @@ "./CheckIcon": "./src/components/icons/CheckIcon.tsx", "./Checkbox": "./src/components/Checkbox.tsx", "./Chip": "./src/components/Chip.tsx", + "./CommentButton": "./src/components/CommentButton.tsx", "./Dialog": "./src/components/Dialog.tsx", "./DropDownButton": "./src/components/DropDownButton.tsx", "./EditableText": "./src/components/EditableText.tsx", From f7d88e4990066ce62d842a4271695b3e458dca1b Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Wed, 23 Jul 2025 17:21:34 +0200 Subject: [PATCH 09/28] Styles on CommentButton for hover, pressed, etc. --- packages/ui/src/components/CommentButton.tsx | 22 +++----------------- 1 file changed, 3 insertions(+), 19 deletions(-) diff --git a/packages/ui/src/components/CommentButton.tsx b/packages/ui/src/components/CommentButton.tsx index 143f41b00..ba8efc854 100644 --- a/packages/ui/src/components/CommentButton.tsx +++ b/packages/ui/src/components/CommentButton.tsx @@ -2,21 +2,9 @@ import { Button as RACButton } from 'react-aria-components'; import { tv } from 'tailwind-variants'; -import type { VariantProps } from 'tailwind-variants'; const commentButtonStyle = tv({ - base: 'flex h-8 items-center justify-center gap-1 px-2 py-1 rounded text-xs font-normal leading-[1.5] text-nowrap outline-none transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#0046c2]', - variants: { - variant: { - default: 'bg-neutral-offWhite text-neutral-gray4', - hover: 'bg-neutral-gray1 text-neutral-charcoal', - pressed: 'bg-neutral-gray2 text-neutral-black', - focus: 'bg-neutral-offWhite text-neutral-gray4 outline outline-2 outline-[#0046c2]', - }, - }, - defaultVariants: { - variant: 'default', - }, + base: 'flex h-8 items-center justify-center gap-1 px-2 py-1 rounded text-xs font-normal leading-[1.5] text-nowrap outline-none transition-colors bg-neutral-offWhite text-neutral-gray4 hover:bg-neutral-gray1 hover:text-neutral-charcoal pressed:bg-neutral-gray2 pressed:text-neutral-black focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#0046c2]', }); const iconStyle = tv({ @@ -44,17 +32,13 @@ const MessageCircleIcon = ({ className }: { className?: string }) => ( ); -type CommentButtonVariants = VariantProps; - export interface CommentButtonProps - extends Omit, 'children'>, - CommentButtonVariants { + extends Omit, 'children'> { count?: number; className?: string; } export const CommentButton = ({ - variant = 'default', count = 0, className, ...props @@ -62,7 +46,7 @@ export const CommentButton = ({ return ( {count} comments From 9ba82b34eff34a44f6245932ff5133376e0696f6 Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Wed, 23 Jul 2025 19:07:50 +0200 Subject: [PATCH 10/28] comment counts --- apps/app/src/components/PostFeed/index.tsx | 4 +- .../common/src/services/posts/createPost.ts | 1 + .../common/src/services/posts/getPosts.ts | 10 ++-- .../common/src/services/posts/listPosts.ts | 49 +++++++++++++++---- services/api/src/encoders/posts.ts | 1 + .../listRelatedOrganizationPosts.ts | 6 ++- 6 files changed, 53 insertions(+), 18 deletions(-) diff --git a/apps/app/src/components/PostFeed/index.tsx b/apps/app/src/components/PostFeed/index.tsx index 626d48ee7..24675af1e 100644 --- a/apps/app/src/components/PostFeed/index.tsx +++ b/apps/app/src/components/PostFeed/index.tsx @@ -289,7 +289,7 @@ const PostCommentButton = ({ return ( ); @@ -427,7 +427,7 @@ const PostsList = ({ -
+
{ ...newPost, reactionCounts: {}, userReactions: [], + commentCount: 0, }; } catch (error) { console.error('Error creating post:', error); diff --git a/packages/common/src/services/posts/getPosts.ts b/packages/common/src/services/posts/getPosts.ts index 2f3991d6d..73667a3d2 100644 --- a/packages/common/src/services/posts/getPosts.ts +++ b/packages/common/src/services/posts/getPosts.ts @@ -3,7 +3,7 @@ import { posts, postsToOrganizations } from '@op/db/schema'; import { and, desc, eq, isNull } from 'drizzle-orm'; import { getCurrentProfileId } from '../access'; -import { getItemsWithReactions } from './listPosts'; +import { getItemsWithReactionsAndComments } from './listPosts'; export interface GetPostsInput { organizationId?: string; @@ -151,12 +151,12 @@ export const getPosts = async (input: GetPostsInput) => { // Transform to match expected format and add reaction data const actorProfileId = await getCurrentProfileId(); - const itemsWithReactions = getItemsWithReactions({ + const itemsWithReactionsAndComments = await getItemsWithReactionsAndComments({ items: orgPosts, profileId: actorProfileId, }); - return itemsWithReactions; + return itemsWithReactionsAndComments; } // Execute query for non-organization posts @@ -164,12 +164,12 @@ export const getPosts = async (input: GetPostsInput) => { // Add reaction counts and user reactions const actorProfileId = await getCurrentProfileId(); - const itemsWithReactions = getItemsWithReactions({ + const itemsWithReactionsAndComments = await getItemsWithReactionsAndComments({ items: result.map(post => ({ post })), profileId: actorProfileId, }); - return itemsWithReactions.map(item => item.post); + return itemsWithReactionsAndComments.map(item => item.post); } catch (error) { console.error('Error fetching posts:', error); throw error; diff --git a/packages/common/src/services/posts/listPosts.ts b/packages/common/src/services/posts/listPosts.ts index 606d995eb..88dd46fca 100644 --- a/packages/common/src/services/posts/listPosts.ts +++ b/packages/common/src/services/posts/listPosts.ts @@ -1,5 +1,5 @@ -import { and, db, eq, lt, or } from '@op/db/client'; -import { organizations, postsToOrganizations, profiles } from '@op/db/schema'; +import { and, count, db, eq, inArray, isNotNull, lt, or } from '@op/db/client'; +import { organizations, posts, postsToOrganizations, profiles } from '@op/db/schema'; import { User } from '@op/supabase/lib'; import { @@ -104,14 +104,14 @@ export const listPosts = async ({ : null; const actorProfileId = await getCurrentProfileId(); - // Transform items to include reaction counts and user's reactions - const itemsWithReactions = getItemsWithReactions({ + // Transform items to include reaction counts, user's reactions, and comment counts + const itemsWithReactionsAndComments = await getItemsWithReactionsAndComments({ items, profileId: actorProfileId, }); return { - items: itemsWithReactions, + items: itemsWithReactionsAndComments, next: nextCursor, hasMore, }; @@ -123,21 +123,47 @@ export const listPosts = async ({ // Using `any` here because the Drizzle query result has a complex nested structure // that's difficult to type precisely. The function is type-safe internally. -export const getItemsWithReactions = ({ +export const getItemsWithReactionsAndComments = async ({ items, profileId, }: { items: any[]; profileId: string; -}): Array< +}): Promise; userReaction: string | null; + commentCount: number; }; } -> => - items.map((item) => { +>> => { + // Get all post IDs to fetch comment counts + const postIds = items.map((item) => item.post.id).filter(Boolean); + + // Fetch comment counts for all posts in a single query + const commentCountMap: Record = {}; + if (postIds.length > 0) { + const commentCounts = await db + .select({ + parentPostId: posts.parentPostId, + count: count(posts.id) + }) + .from(posts) + .where(and( + isNotNull(posts.parentPostId), + inArray(posts.parentPostId, postIds) + )) + .groupBy(posts.parentPostId); + + commentCounts.forEach((row) => { + if (row.parentPostId) { + commentCountMap[row.parentPostId] = Number(row.count); + } + }); + } + + return items.map((item) => { const reactionCounts: Record = {}; let userReaction: string | null = null; @@ -156,12 +182,17 @@ export const getItemsWithReactions = ({ ); } + // Get comment count for this post + const commentCount = commentCountMap[item.post.id] || 0; + return { ...item, post: { ...item.post, reactionCounts, userReaction, + commentCount, }, }; }); +}; diff --git a/services/api/src/encoders/posts.ts b/services/api/src/encoders/posts.ts index f7c99d164..f239810fc 100644 --- a/services/api/src/encoders/posts.ts +++ b/services/api/src/encoders/posts.ts @@ -15,6 +15,7 @@ export const postsEncoder: z.ZodType = createSelectSchema(posts) attachments: z.array(postAttachmentEncoder).nullish(), reactionCounts: z.record(z.string(), z.number()), userReaction: z.string().nullish(), + commentCount: z.number(), profile: profileWithAvatarEncoder.nullish(), childPosts: z.array(z.lazy(() => postsEncoder)).nullish(), parentPost: z.lazy(() => postsEncoder).nullish(), diff --git a/services/api/src/routers/organization/listRelatedOrganizationPosts.ts b/services/api/src/routers/organization/listRelatedOrganizationPosts.ts index c2eeddf50..bd53c1502 100644 --- a/services/api/src/routers/organization/listRelatedOrganizationPosts.ts +++ b/services/api/src/routers/organization/listRelatedOrganizationPosts.ts @@ -2,7 +2,7 @@ import { decodeCursor, encodeCursor, getCurrentProfileId, - getItemsWithReactions, + getItemsWithReactionsAndComments, getRelatedOrganizations, } from '@op/common'; import { and, eq, inArray, lt, or } from '@op/db/client'; @@ -131,8 +131,10 @@ export const listRelatedOrganizationPostsRouter = router({ ? encodeCursor(new Date(lastItem.createdAt), lastItem.postId) : null; + const itemsWithReactionsAndComments = await getItemsWithReactionsAndComments({ items, profileId }); + return { - items: getItemsWithReactions({ items, profileId }).map((postToOrg) => ({ + items: itemsWithReactionsAndComments.map((postToOrg) => ({ ...postToOrg, organization: organizationsEncoder.parse(postToOrg.organization), post: postsEncoder.parse(postToOrg.post), From 786edae1478c08c8df23f671f639a191f2370111 Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Wed, 23 Jul 2025 19:22:09 +0200 Subject: [PATCH 11/28] Update comment button style --- packages/ui/src/components/CommentButton.tsx | 11 ++- packages/ui/stories/CommentButton.stories.tsx | 68 +++++++++++++++++++ 2 files changed, 72 insertions(+), 7 deletions(-) create mode 100644 packages/ui/stories/CommentButton.stories.tsx diff --git a/packages/ui/src/components/CommentButton.tsx b/packages/ui/src/components/CommentButton.tsx index ba8efc854..05c40a01b 100644 --- a/packages/ui/src/components/CommentButton.tsx +++ b/packages/ui/src/components/CommentButton.tsx @@ -4,11 +4,11 @@ import { Button as RACButton } from 'react-aria-components'; import { tv } from 'tailwind-variants'; const commentButtonStyle = tv({ - base: 'flex h-8 items-center justify-center gap-1 px-2 py-1 rounded text-xs font-normal leading-[1.5] text-nowrap outline-none transition-colors bg-neutral-offWhite text-neutral-gray4 hover:bg-neutral-gray1 hover:text-neutral-charcoal pressed:bg-neutral-gray2 pressed:text-neutral-black focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#0046c2]', + base: 'flex h-8 items-center justify-center gap-1 text-nowrap rounded-sm bg-neutral-offWhite px-2 py-1 text-sm text-neutral-gray4 outline-none transition-colors hover:bg-neutral-gray1 hover:text-neutral-charcoal focus-visible:bg-neutral-offWhite focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-data-blue pressed:bg-neutral-gray2 pressed:text-neutral-black', }); const iconStyle = tv({ - base: 'w-4 h-4 shrink-0', + base: 'h-4 w-4 shrink-0', }); // Message Circle Icon SVG @@ -44,12 +44,9 @@ export const CommentButton = ({ ...props }: CommentButtonProps) => { return ( - + {count} comments ); -}; \ No newline at end of file +}; diff --git a/packages/ui/stories/CommentButton.stories.tsx b/packages/ui/stories/CommentButton.stories.tsx new file mode 100644 index 000000000..c8c746a30 --- /dev/null +++ b/packages/ui/stories/CommentButton.stories.tsx @@ -0,0 +1,68 @@ +import type { Meta } from '@storybook/react'; +import { CommentButton } from '../src/components/CommentButton'; + +const meta: Meta = { + title: 'CommentButton', + component: CommentButton, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], + argTypes: { + count: { + control: 'number', + }, + isDisabled: { + control: 'boolean', + }, + className: { + control: 'text', + }, + }, + args: { + count: 0, + isDisabled: false, + }, +}; + +export default meta; + +export const Example = () => ( +
+
+ + + + +
+
+ + +
+
+); + +export const NoComments = { + args: { + count: 0, + }, +}; + +export const WithComments = { + args: { + count: 23, + }, +}; + +export const ManyComments = { + args: { + count: 1247, + }, +}; + +export const Disabled = { + args: { + count: 15, + isDisabled: true, + }, +}; \ No newline at end of file From 48fabfc20917434f96c7619d52321aa19fe7242c Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Wed, 23 Jul 2025 20:12:46 +0200 Subject: [PATCH 12/28] Update IconButton component --- apps/app/src/components/PostFeed/index.tsx | 12 +- packages/ui/package.json | 1 + packages/ui/src/components/IconButton.tsx | 48 +++++++ packages/ui/src/index.ts | 1 + packages/ui/stories/IconButton.stories.tsx | 153 +++++++++++++++++++++ 5 files changed, 208 insertions(+), 7 deletions(-) create mode 100644 packages/ui/src/components/IconButton.tsx create mode 100644 packages/ui/stories/IconButton.stories.tsx diff --git a/apps/app/src/components/PostFeed/index.tsx b/apps/app/src/components/PostFeed/index.tsx index 24675af1e..4a46b1744 100644 --- a/apps/app/src/components/PostFeed/index.tsx +++ b/apps/app/src/components/PostFeed/index.tsx @@ -12,7 +12,7 @@ import type { } from '@op/api/encoders'; import { REACTION_OPTIONS } from '@op/types'; import { AvatarSkeleton } from '@op/ui/Avatar'; -import { Button } from '@op/ui/Button'; +import { IconButton } from '@op/ui/IconButton'; import { CommentButton } from '@op/ui/CommentButton'; import { Header3 } from '@op/ui/Header'; import { MediaDisplay } from '@op/ui/MediaDisplay'; @@ -315,15 +315,13 @@ const PostMenu = ({ return ( - + ; + +export interface IconButtonProps + extends Omit, 'children'>, + IconButtonVariants { + children: React.ReactNode; + className?: string; +} + +export const IconButton = (props: IconButtonProps) => { + const { children, className, ...rest } = props; + + return ( + + {children} + + ); +}; \ No newline at end of file diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 4c81a0f9d..a9ef676ab 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -26,6 +26,7 @@ export * from './components/Field'; export * from './components/Form'; export * from './components/GridList'; export * from './components/Header'; +export * from './components/IconButton'; export * from './components/Keyboard'; export * from './components/Link'; export * from './components/ListBox'; diff --git a/packages/ui/stories/IconButton.stories.tsx b/packages/ui/stories/IconButton.stories.tsx new file mode 100644 index 000000000..1e56fe513 --- /dev/null +++ b/packages/ui/stories/IconButton.stories.tsx @@ -0,0 +1,153 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import { IconButton } from '../src/components/IconButton'; + +const meta: Meta = { + title: 'Components/IconButton', + component: IconButton, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], + argTypes: { + size: { + control: { type: 'select' }, + options: ['small', 'medium', 'large'], + }, + variant: { + control: { type: 'select' }, + options: ['ghost', 'solid', 'outline'], + }, + isDisabled: { + control: 'boolean', + }, + }, +}; + +export default meta; +type Story = StoryObj; + +const DummyIcon = () => ( + + + + + +); + +export const Default: Story = { + args: { + children: , + }, +}; + +export const Small: Story = { + args: { + size: 'small', + children: , + }, +}; + +export const Medium: Story = { + args: { + size: 'medium', + children: , + }, +}; + +export const Large: Story = { + args: { + size: 'large', + children: , + }, +}; + +export const Ghost: Story = { + args: { + variant: 'ghost', + children: , + }, +}; + +export const Solid: Story = { + args: { + variant: 'solid', + children: , + }, +}; + +export const Outline: Story = { + args: { + variant: 'outline', + children: , + }, +}; + +export const Disabled: Story = { + args: { + isDisabled: true, + children: , + }, +}; + +export const AllVariants: Story = { + render: () => ( +
+
+ Ghost + + + +
+
+ Solid + + + +
+
+ Outline + + + +
+
+ Disabled + + + +
+
+ ), +}; + +export const AllSizes: Story = { + render: () => ( +
+
+ Small + + + +
+
+ Medium + + + +
+
+ Large + + + +
+
+ ), +}; \ No newline at end of file From ac9ca22092e32573b104390f20ebba50e5f01539 Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Wed, 23 Jul 2025 20:15:48 +0200 Subject: [PATCH 13/28] feature flag comments --- apps/app/src/components/PostFeed/index.tsx | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/apps/app/src/components/PostFeed/index.tsx b/apps/app/src/components/PostFeed/index.tsx index 4a46b1744..c7b5d68b1 100644 --- a/apps/app/src/components/PostFeed/index.tsx +++ b/apps/app/src/components/PostFeed/index.tsx @@ -12,9 +12,9 @@ import type { } from '@op/api/encoders'; import { REACTION_OPTIONS } from '@op/types'; import { AvatarSkeleton } from '@op/ui/Avatar'; -import { IconButton } from '@op/ui/IconButton'; import { CommentButton } from '@op/ui/CommentButton'; import { Header3 } from '@op/ui/Header'; +import { IconButton } from '@op/ui/IconButton'; import { MediaDisplay } from '@op/ui/MediaDisplay'; import { MenuTrigger } from '@op/ui/Menu'; import { Popover } from '@op/ui/Popover'; @@ -23,6 +23,7 @@ import { Skeleton, SkeletonLine } from '@op/ui/Skeleton'; import { toast } from '@op/ui/Toast'; import { cn } from '@op/ui/utils'; import Image from 'next/image'; +import { useFeatureFlagEnabled } from 'posthog-js/react'; import { Fragment, ReactNode, useState } from 'react'; import { LuEllipsis, LuLeaf } from 'react-icons/lu'; @@ -285,13 +286,11 @@ const PostCommentButton = ({ isComment: boolean; onCommentClick: () => void; }) => { - if (!post?.id || isComment) return null; + const commentsEnabled = useFeatureFlagEnabled('comments'); + if (!commentsEnabled || !post?.id || isComment) return null; return ( - + ); }; From 2f4929eafb8009b8f555c985aa0926f40b90dbc1 Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Wed, 23 Jul 2025 20:20:11 +0200 Subject: [PATCH 14/28] fix types --- apps/app/src/components/DiscussionModal/index.tsx | 6 ++++++ apps/app/src/components/PostFeed/index.tsx | 3 ++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/app/src/components/DiscussionModal/index.tsx b/apps/app/src/components/DiscussionModal/index.tsx index 58ba74831..1611e0a11 100644 --- a/apps/app/src/components/DiscussionModal/index.tsx +++ b/apps/app/src/components/DiscussionModal/index.tsx @@ -38,6 +38,7 @@ export function DiscussionModal({ { enabled: isOpen }, ); + useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape' && isOpen) { @@ -61,6 +62,7 @@ export function DiscussionModal({ }); }; + // Get the post author's name for the header const authorName = post?.profile?.name || 'Unknown'; @@ -119,8 +121,10 @@ export function DiscussionModal({ organization: post.organization || organization || null, }, ]} + user={user} withLinks={false} className="border-none" + slug={organization?.profile?.slug || post.organization?.profile?.slug} /> {/* Comments Display */} {isLoading ? ( @@ -130,8 +134,10 @@ export function DiscussionModal({ ) : comments.length > 0 ? ( ) : (
diff --git a/apps/app/src/components/PostFeed/index.tsx b/apps/app/src/components/PostFeed/index.tsx index c7b5d68b1..ce1386c54 100644 --- a/apps/app/src/components/PostFeed/index.tsx +++ b/apps/app/src/components/PostFeed/index.tsx @@ -396,6 +396,7 @@ const PostsList = ({ { + const handleCommentClick = (post: Post, org?: Organization | null) => { setDiscussionModal({ isOpen: true, post, org }); }; From eae864e9ad091a08537c6931516dea6ebb075326 Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Thu, 24 Jul 2025 10:07:27 +0200 Subject: [PATCH 15/28] Remove any type on table! --- services/db/schema/tables/posts.sql.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/db/schema/tables/posts.sql.ts b/services/db/schema/tables/posts.sql.ts index a6a6bca52..e30537426 100644 --- a/services/db/schema/tables/posts.sql.ts +++ b/services/db/schema/tables/posts.sql.ts @@ -8,7 +8,7 @@ import { organizations } from './organizations.sql'; import { postReactions } from './postReactions.sql'; import { profiles } from './profiles.sql'; -export const posts: any = pgTable( +export const posts = pgTable( 'posts', { id: autoId().primaryKey(), From 1621e3b838e9006892e0739b730d778e2874a065 Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Thu, 24 Jul 2025 12:27:14 +0200 Subject: [PATCH 16/28] update prettier formatting --- .prettierrc.json | 1 + 1 file changed, 1 insertion(+) diff --git a/.prettierrc.json b/.prettierrc.json index adc4a813c..b4e28a8aa 100644 --- a/.prettierrc.json +++ b/.prettierrc.json @@ -1,4 +1,5 @@ { + "bracketSameLine": false, "singleQuote": true, "tabWidth": 2, "semi": true, From e27ac04c6d815a5d673aa0ddb5a89d6838ff1637 Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Thu, 24 Jul 2025 12:30:03 +0200 Subject: [PATCH 17/28] update types --- apps/app/src/components/PostFeed/index.tsx | 18 +++++++++------- .../common/src/services/posts/createPost.ts | 4 ++++ services/api/src/encoders/posts.ts | 9 ++++---- services/api/src/encoders/storageItem.ts | 2 ++ .../src/routers/account/getUserProfiles.ts | 21 +++++++++++++++---- services/db/schema/publicTables.ts | 8 ++++--- services/db/schema/tables/storage.sql.ts | 4 +++- 7 files changed, 46 insertions(+), 20 deletions(-) diff --git a/apps/app/src/components/PostFeed/index.tsx b/apps/app/src/components/PostFeed/index.tsx index ce1386c54..0e19d2310 100644 --- a/apps/app/src/components/PostFeed/index.tsx +++ b/apps/app/src/components/PostFeed/index.tsx @@ -164,8 +164,10 @@ const PostDisplayName = ({ return <>{displayName}; }; -const PostTimestamp = ({ createdAt }: { createdAt?: Date | string }) => { - if (!createdAt) return null; +const PostTimestamp = ({ createdAt }: { createdAt?: Date | string | null }) => { + if (!createdAt) { + return null; + } return ( @@ -175,13 +177,17 @@ const PostTimestamp = ({ createdAt }: { createdAt?: Date | string }) => { }; const PostContent = ({ content }: { content?: string }) => { - if (!content) return null; + if (!content) { + return null; + } return <>{linkifyText(content)}; }; const PostAttachments = ({ attachments }: { attachments?: any[] }) => { - if (!attachments) return null; + if (!attachments) { + return null; + } return ( <> @@ -388,10 +394,6 @@ const PostsList = ({ ? { profile: post?.profile as Profile } : organization; - if (organization && post.content === 'happy wednesday everyone!') { - console.log('ORGPOST', organization, post, displayName); - } - return ( diff --git a/packages/common/src/services/posts/createPost.ts b/packages/common/src/services/posts/createPost.ts index 747a951e1..6bc856595 100644 --- a/packages/common/src/services/posts/createPost.ts +++ b/packages/common/src/services/posts/createPost.ts @@ -39,6 +39,10 @@ export const createPost = async (input: CreatePostInput) => { }) .returning(); + if (!newPost) { + throw new CommonError('Failed to create post'); + } + // If organizationId is provided, create the organization association if (organizationId) { await db.insert(postsToOrganizations).values({ diff --git a/services/api/src/encoders/posts.ts b/services/api/src/encoders/posts.ts index f239810fc..abba55bc8 100644 --- a/services/api/src/encoders/posts.ts +++ b/services/api/src/encoders/posts.ts @@ -10,15 +10,16 @@ export const postAttachmentEncoder = createSelectSchema(attachments).extend({ storageObject: storageItemEncoder, }); -export const postsEncoder: z.ZodType = createSelectSchema(posts) +export const postsEncoder = createSelectSchema(posts) .extend({ - attachments: z.array(postAttachmentEncoder).nullish(), + attachments: z.array(postAttachmentEncoder).default([]), reactionCounts: z.record(z.string(), z.number()), userReaction: z.string().nullish(), commentCount: z.number(), profile: profileWithAvatarEncoder.nullish(), - childPosts: z.array(z.lazy(() => postsEncoder)).nullish(), - parentPost: z.lazy(() => postsEncoder).nullish(), + // TODO: circular references produce issues in zod so are typed as any for now + childPosts: z.array(z.lazy((): z.ZodType => postsEncoder)).nullish(), + parentPost: z.lazy((): z.ZodType => postsEncoder).nullish(), }) .strip(); diff --git a/services/api/src/encoders/storageItem.ts b/services/api/src/encoders/storageItem.ts index 71482365b..aa3294e80 100644 --- a/services/api/src/encoders/storageItem.ts +++ b/services/api/src/encoders/storageItem.ts @@ -20,3 +20,5 @@ export const storageItemEncoder = createSelectSchema(objectsInStorage) httpStatusCode: z.number(), }), }); + +export type StorageItem = z.infer; diff --git a/services/api/src/routers/account/getUserProfiles.ts b/services/api/src/routers/account/getUserProfiles.ts index fef9e69f1..2537122f9 100644 --- a/services/api/src/routers/account/getUserProfiles.ts +++ b/services/api/src/routers/account/getUserProfiles.ts @@ -1,5 +1,5 @@ import { UnauthorizedError } from '@op/common'; -import { EntityType } from '@op/db/schema'; +import { EntityType, ObjectsInStorage, Profile } from '@op/db/schema'; import type { OpenApiMeta } from 'trpc-to-openapi'; import { z } from 'zod'; @@ -40,7 +40,18 @@ export const getUserProfiles = router({ .use(withDB) .meta(meta) .input(z.undefined()) - .output(z.array(userProfileSchema)) + .output( + z.array( + userProfileSchema.extend({ + avatarImage: z + .object({ + id: z.string(), + name: z.string().nullable(), + }) + .nullable(), + }), + ), + ) .query(async ({ ctx }) => { const { db } = ctx.database; const { id: authUserId } = ctx.user; @@ -80,12 +91,14 @@ export const getUserProfiles = router({ name: string; slug: string; bio: string | null; - avatarImage: { id: string; name: string } | null; + avatarImage: { id: string; name: string | null } | null; }> = []; // Add user's personal profile if it exists if (user.profile) { - const profile = user.profile as any; + const profile = user.profile as Profile & { + avatarImage: ObjectsInStorage; + }; userProfiles.push({ id: profile.id, type: EntityType.INDIVIDUAL, diff --git a/services/db/schema/publicTables.ts b/services/db/schema/publicTables.ts index 0dfb99a8f..aa32d2d06 100644 --- a/services/db/schema/publicTables.ts +++ b/services/db/schema/publicTables.ts @@ -38,11 +38,11 @@ export { postReactionsRelations, } from './tables/postReactions.sql'; -export { - comments, +export { + comments, commentsRelations, commentsToPost, - commentsToPostRelations + commentsToPostRelations, } from './tables/comments.sql'; export type { Comment, CommentToPost } from './tables/comments.sql'; @@ -67,3 +67,5 @@ export { profiles, profilesRelations } from './tables/profiles.sql'; export type { Profile } from './tables/profiles.sql'; export { EntityType, entityTypeEnum } from './tables/entities.sql'; export { allowList, allowListRelations } from './tables/allowList.sql'; + +export type { ObjectsInStorage } from './tables/storage.sql'; diff --git a/services/db/schema/tables/storage.sql.ts b/services/db/schema/tables/storage.sql.ts index b06c04b52..61c1994ef 100644 --- a/services/db/schema/tables/storage.sql.ts +++ b/services/db/schema/tables/storage.sql.ts @@ -1,4 +1,4 @@ -import { sql } from 'drizzle-orm'; +import { InferModel, sql } from 'drizzle-orm'; import { bigint, boolean, @@ -81,3 +81,5 @@ export const objectsInStorage = storageSchema.table( index('idx_objects_bucket_id_name').on(table.bucketId, table.name), ], ); + +export type ObjectsInStorage = InferModel; From 73c587681674fbb8db32d6ab8d8bb7f576f206ce Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Thu, 24 Jul 2025 13:13:04 +0200 Subject: [PATCH 18/28] update typings --- .../components/OrganizationAvatar/index.tsx | 10 +---- apps/app/src/components/PostFeed/index.tsx | 40 +------------------ .../ProfileDetails/AddRelationshipModal.tsx | 2 +- apps/app/src/components/utils/index.ts | 37 +++++++++++++++++ 4 files changed, 42 insertions(+), 47 deletions(-) create mode 100644 apps/app/src/components/utils/index.ts diff --git a/apps/app/src/components/OrganizationAvatar/index.tsx b/apps/app/src/components/OrganizationAvatar/index.tsx index 2d099274d..5562ea530 100644 --- a/apps/app/src/components/OrganizationAvatar/index.tsx +++ b/apps/app/src/components/OrganizationAvatar/index.tsx @@ -1,22 +1,17 @@ import { getPublicUrl } from '@/utils'; -import { RouterOutput } from '@op/api/client'; +import { Profile } from '@op/api/encoders'; import { Avatar, AvatarSkeleton } from '@op/ui/Avatar'; import { cn } from '@op/ui/utils'; import Image from 'next/image'; import { Link } from '@/lib/i18n'; -type relationshipOrganization = - | RouterOutput['organization']['listRelationships']['organizations'][number] - | RouterOutput['organization']['list']['items'][number] - | RouterOutput['organization']['listPosts']['items'][number]['organization']; - export const OrganizationAvatar = ({ organization, withLink = true, className, }: { - organization?: relationshipOrganization; + organization?: { profile?: Profile }; withLink?: boolean; className?: string; }) => { @@ -24,7 +19,6 @@ export const OrganizationAvatar = ({ return null; } - // TODO: fix type resolution in drizzle. const profile = 'profile' in organization ? organization.profile : null; const name = profile?.name ?? ''; diff --git a/apps/app/src/components/PostFeed/index.tsx b/apps/app/src/components/PostFeed/index.tsx index 0e19d2310..7b823ae17 100644 --- a/apps/app/src/components/PostFeed/index.tsx +++ b/apps/app/src/components/PostFeed/index.tsx @@ -32,45 +32,9 @@ import { Link } from '@/lib/i18n'; import { DiscussionModal } from '../DiscussionModal'; import { LinkPreview } from '../LinkPreview'; import { OrganizationAvatar } from '../OrganizationAvatar'; +import { formatRelativeTime } from '../utils'; import { DeletePost } from './DeletePost'; -// TODO: generated this quick with AI. refactor it! -const formatRelativeTime = (timestamp: Date | string | number): string => { - const now = new Date(); - const date = new Date(timestamp); - const diff = Math.floor((now.getTime() - date.getTime()) / 1000); // difference in seconds - - // Future dates handling - if (diff < 0) { - return 'in the future'; - } - - // For very recent times - if (diff < 5) { - return 'just now'; - } - - const intervals = [ - { unit: 'year', seconds: 31557600 }, - { unit: 'month', seconds: 2629800 }, - { unit: 'week', seconds: 604800 }, - { unit: 'day', seconds: 86400 }, - { unit: 'hour', seconds: 3600 }, - { unit: 'minute', seconds: 60 }, - { unit: 'second', seconds: 1 }, - ]; - - for (const interval of intervals) { - if (diff >= interval.seconds) { - const count = Math.floor(diff / interval.seconds); - - return `${count} ${interval.unit}${count !== 1 ? 's' : ''}`; - } - } - - return 'just now'; -}; - export const FeedItem = ({ children, className, @@ -383,6 +347,7 @@ const PostsList = ({ const { urls } = detectLinks(post?.content); // For comments (posts without organization), show the post author + // TODO: this is too complex. We need to refactor this const isComment = !organization; const displayName = isComment ? post?.profile?.name @@ -398,7 +363,6 @@ const PostsList = ({ with diff --git a/apps/app/src/components/utils/index.ts b/apps/app/src/components/utils/index.ts new file mode 100644 index 000000000..407dd2ae2 --- /dev/null +++ b/apps/app/src/components/utils/index.ts @@ -0,0 +1,37 @@ +export const formatRelativeTime = ( + timestamp: Date | string | number, +): string => { + const now = new Date(); + const date = new Date(timestamp); + const diff = Math.floor((now.getTime() - date.getTime()) / 1000); // difference in seconds + + // Future dates handling + if (diff < 0) { + return 'in the future'; + } + + // For very recent times + if (diff < 5) { + return 'just now'; + } + + const intervals = [ + { unit: 'year', seconds: 31557600 }, + { unit: 'month', seconds: 2629800 }, + { unit: 'week', seconds: 604800 }, + { unit: 'day', seconds: 86400 }, + { unit: 'hour', seconds: 3600 }, + { unit: 'minute', seconds: 60 }, + { unit: 'second', seconds: 1 }, + ]; + + for (const interval of intervals) { + if (diff >= interval.seconds) { + const count = Math.floor(diff / interval.seconds); + + return `${count} ${interval.unit}${count !== 1 ? 's' : ''}`; + } + } + + return 'just now'; +}; From 775ab56e072bd4c2c2a5358ad42359fa62b66fe9 Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Thu, 24 Jul 2025 14:23:49 +0200 Subject: [PATCH 19/28] fixup typings on DiscussionModal --- .../src/components/DiscussionModal/index.tsx | 63 +++++-------------- apps/app/src/components/PostFeed/index.tsx | 32 +++++----- .../ProfileDetails/UpdateUserProfileModal.tsx | 43 +++---------- 3 files changed, 37 insertions(+), 101 deletions(-) diff --git a/apps/app/src/components/DiscussionModal/index.tsx b/apps/app/src/components/DiscussionModal/index.tsx index 1611e0a11..bbadb6bfc 100644 --- a/apps/app/src/components/DiscussionModal/index.tsx +++ b/apps/app/src/components/DiscussionModal/index.tsx @@ -2,35 +2,31 @@ import { useUser } from '@/utils/UserProvider'; import { trpc } from '@op/api/client'; -import type { Organization, Post } from '@op/api/encoders'; +import type { PostToOrganization } from '@op/api/encoders'; import { Button } from '@op/ui/Button'; import { Modal, ModalFooter, ModalHeader } from '@op/ui/Modal'; import { Surface } from '@op/ui/Surface'; -import { useEffect } from 'react'; import { LuX } from 'react-icons/lu'; import { PostFeed } from '../PostFeed'; import { PostUpdate } from '../PostUpdate'; -interface DiscussionModalProps { - post: Post; - organization?: Organization | null; - isOpen: boolean; - onClose: () => void; -} - export function DiscussionModal({ - post, - organization, + postToOrg, isOpen, onClose, -}: DiscussionModalProps) { +}: { + postToOrg: PostToOrganization; + isOpen: boolean; + onClose: () => void; +}) { const utils = trpc.useUtils(); const { user } = useUser(); + const { post, organizationId, organization } = postToOrg; const { data: commentsData, isLoading } = trpc.posts.getPosts.useQuery( { - parentPostId: post.id ?? null, // Get comments (child posts) of this post + parentPostId: post.id, // Get comments (child posts) of this post limit: 50, offset: 0, includeChildren: false, @@ -38,35 +34,18 @@ export function DiscussionModal({ { enabled: isOpen }, ); - - useEffect(() => { - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === 'Escape' && isOpen) { - event.preventDefault(); - onClose(); - } - }; - - if (isOpen) { - document.addEventListener('keydown', handleKeyDown); - } - - return () => { - document.removeEventListener('keydown', handleKeyDown); - }; - }, [isOpen, onClose]); - const handleCommentSuccess = () => { utils.posts.getPosts.invalidate({ - parentPostId: post.id ?? null, // Invalidate comments for this post + parentPostId: post.id, // Invalidate comments for this post }); }; + const sourcePostProfile = post.profile; // Get the post author's name for the header - const authorName = post?.profile?.name || 'Unknown'; + const authorName = sourcePostProfile?.name || 'Unknown'; - // Transform comments data to match PostFeed expected format + // Transform comments data to match PostFeeds expected PostToOrganizaion format const comments = commentsData?.map((comment) => ({ createdAt: comment.createdAt, @@ -110,21 +89,11 @@ export function DiscussionModal({
{/* Original Post Display */} {/* Comments Display */} {isLoading ? ( @@ -137,7 +106,7 @@ export function DiscussionModal({ user={user} withLinks={false} className="border-none" - slug={organization?.profile?.slug || post.organization?.profile?.slug} + slug={organization?.profile?.slug} /> ) : (
diff --git a/apps/app/src/components/PostFeed/index.tsx b/apps/app/src/components/PostFeed/index.tsx index 7b823ae17..145fb3a0c 100644 --- a/apps/app/src/components/PostFeed/index.tsx +++ b/apps/app/src/components/PostFeed/index.tsx @@ -4,12 +4,7 @@ import { getPublicUrl } from '@/utils'; import { OrganizationUser } from '@/utils/UserProvider'; import { detectLinks, linkifyText } from '@/utils/linkDetection'; import { trpc } from '@op/api/client'; -import type { - Organization, - Post, - PostToOrganization, - Profile, -} from '@op/api/encoders'; +import type { PostToOrganization, Profile } from '@op/api/encoders'; import { REACTION_OPTIONS } from '@op/types'; import { AvatarSkeleton } from '@op/ui/Avatar'; import { CommentButton } from '@op/ui/CommentButton'; @@ -340,10 +335,11 @@ const PostsList = ({ user?: OrganizationUser; withLinks: boolean; onReactionClick: (postId: string, emoji: string) => void; - onCommentClick: (post: Post, org?: Organization | null) => void; + onCommentClick: (post: PostToOrganization) => void; }) => ( <> - {posts.map(({ organization, post }, i) => { + {posts.map((postToOrg, i) => { + const { organization, post } = postToOrg; const { urls } = detectLinks(post?.content); // For comments (posts without organization), show the post author @@ -399,7 +395,7 @@ const PostsList = ({ onCommentClick(post, organization)} + onCommentClick={() => onCommentClick(postToOrg)} />
@@ -416,7 +412,10 @@ const DiscussionModalContainer = ({ discussionModal, onClose, }: { - discussionModal: { isOpen: boolean; post: Post; org?: Organization | null }; + discussionModal: { + isOpen: boolean; + post?: PostToOrganization | null; + }; onClose: () => void; }) => { if (!discussionModal.isOpen || !discussionModal.post) { @@ -425,8 +424,7 @@ const DiscussionModalContainer = ({ return ( @@ -451,12 +449,10 @@ export const PostFeed = ({ const utils = trpc.useUtils(); const [discussionModal, setDiscussionModal] = useState<{ isOpen: boolean; - post: any; - org?: Organization | null; + post?: PostToOrganization | null; }>({ isOpen: false, post: null, - org: null, }); const toggleReaction = trpc.organization.toggleReaction.useMutation({ @@ -591,12 +587,12 @@ export const PostFeed = ({ toggleReaction.mutate({ postId, reactionType }); }; - const handleCommentClick = (post: Post, org?: Organization | null) => { - setDiscussionModal({ isOpen: true, post, org }); + const handleCommentClick = (post: PostToOrganization) => { + setDiscussionModal({ isOpen: true, post }); }; const handleModalClose = () => { - setDiscussionModal({ isOpen: false, post: null, org: null }); + setDiscussionModal({ isOpen: false, post: null }); }; return ( diff --git a/apps/app/src/components/Profile/ProfileDetails/UpdateUserProfileModal.tsx b/apps/app/src/components/Profile/ProfileDetails/UpdateUserProfileModal.tsx index c186d297b..f235f0031 100644 --- a/apps/app/src/components/Profile/ProfileDetails/UpdateUserProfileModal.tsx +++ b/apps/app/src/components/Profile/ProfileDetails/UpdateUserProfileModal.tsx @@ -5,7 +5,8 @@ import type { Profile } from '@op/api/encoders'; import { Button } from '@op/ui/Button'; import { Modal, ModalHeader } from '@op/ui/Modal'; import { DialogTrigger } from '@op/ui/RAC'; -import { useEffect, useRef, useState } from 'react'; +import { useOverlayTriggerState } from '@op/ui/RAS'; +import { useRef } from 'react'; import { LuPencil, LuX } from 'react-icons/lu'; import { useTranslations } from '@/lib/i18n'; @@ -21,46 +22,23 @@ export const UpdateUserProfileModal = ({ }: UpdateUserProfileModalProps) => { const { user } = useUser(); const t = useTranslations(); - const [isOpen, setIsOpen] = useState(false); const formRef = useRef(null); + const state = useOverlayTriggerState({}); // Only show edit button if this is the user's own profile const canEdit = user?.currentProfile?.id === profile.id; - useEffect(() => { - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === 'Escape' && isOpen) { - event.preventDefault(); - setIsOpen(false); - } - }; - - if (isOpen) { - document.addEventListener('keydown', handleKeyDown); - } - - return () => { - document.removeEventListener('keydown', handleKeyDown); - }; - }, [isOpen]); - if (!canEdit) { return null; } return ( - @@ -68,19 +46,12 @@ export const UpdateUserProfileModal = ({ {/* Desktop header */}
{t('Edit Profile')} - setIsOpen(false)} - /> +
{/* Mobile header */}
-

{t('Edit Profile')}

@@ -98,7 +69,7 @@ export const UpdateUserProfileModal = ({ setIsOpen(false)} + onSuccess={() => state.close()} className="p-6" /> )} From ae70a85c5dc3060947e3065768c3d8eec4e41079 Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Thu, 24 Jul 2025 14:40:18 +0200 Subject: [PATCH 20/28] refactor PostFeed for better composition --- .../src/components/DiscussionModal/index.tsx | 45 +++++++++++------ apps/app/src/components/PostFeed/index.tsx | 50 ++++++++----------- .../components/Profile/ProfileFeed/index.tsx | 34 ++++++++++++- .../components/screens/LandingScreen/Feed.tsx | 37 +++++++++++++- 4 files changed, 117 insertions(+), 49 deletions(-) diff --git a/apps/app/src/components/DiscussionModal/index.tsx b/apps/app/src/components/DiscussionModal/index.tsx index bbadb6bfc..ee3ad2e22 100644 --- a/apps/app/src/components/DiscussionModal/index.tsx +++ b/apps/app/src/components/DiscussionModal/index.tsx @@ -8,7 +8,11 @@ import { Modal, ModalFooter, ModalHeader } from '@op/ui/Modal'; import { Surface } from '@op/ui/Surface'; import { LuX } from 'react-icons/lu'; -import { PostFeed } from '../PostFeed'; +import { + PostFeed, + PostsList, + usePostFeedActions +} from '../PostFeed'; import { PostUpdate } from '../PostUpdate'; export function DiscussionModal({ @@ -22,7 +26,12 @@ export function DiscussionModal({ }) { const utils = trpc.useUtils(); const { user } = useUser(); - const { post, organizationId, organization } = postToOrg; + const { post, organization } = postToOrg; + + const { + handleReactionClick, + handleCommentClick + } = usePostFeedActions({ slug: organization?.profile?.slug }); const { data: commentsData, isLoading } = trpc.posts.getPosts.useQuery( { @@ -88,26 +97,30 @@ export function DiscussionModal({
{/* Original Post Display */} - + + + {/* Comments Display */} {isLoading ? (
Loading discussion...
) : comments.length > 0 ? ( - + + + ) : (
No comments yet. Be the first to comment! diff --git a/apps/app/src/components/PostFeed/index.tsx b/apps/app/src/components/PostFeed/index.tsx index 145fb3a0c..50b3c3a89 100644 --- a/apps/app/src/components/PostFeed/index.tsx +++ b/apps/app/src/components/PostFeed/index.tsx @@ -311,7 +311,7 @@ const PostMenuContent = ({ return ; }; -const EmptyPostsState = () => ( +export const EmptyPostsState = () => ( @@ -324,7 +324,7 @@ const EmptyPostsState = () => ( ); -const PostsList = ({ +export const PostsList = ({ posts, user, withLinks, @@ -408,7 +408,7 @@ const PostsList = ({ ); -const DiscussionModalContainer = ({ +export const DiscussionModalContainer = ({ discussionModal, onClose, }: { @@ -431,21 +431,13 @@ const DiscussionModalContainer = ({ ); }; -export const PostFeed = ({ - posts, - user, - className, - withLinks = true, +export const usePostFeedActions = ({ slug, limit = 20, }: { - posts: Array; - user?: OrganizationUser; - className?: string; - withLinks?: boolean; slug?: string; limit?: number; -}) => { +} = {}) => { const utils = trpc.useUtils(); const [discussionModal, setDiscussionModal] = useState<{ isOpen: boolean; @@ -595,24 +587,24 @@ export const PostFeed = ({ setDiscussionModal({ isOpen: false, post: null }); }; + return { + discussionModal, + handleReactionClick, + handleCommentClick, + handleModalClose, + }; +}; + +export const PostFeed = ({ + children, + className, +}: { + children: ReactNode; + className?: string; +}) => { return (
- {posts.length > 0 ? ( - - ) : ( - - )} - - + {children}
); }; diff --git a/apps/app/src/components/Profile/ProfileFeed/index.tsx b/apps/app/src/components/Profile/ProfileFeed/index.tsx index a7371bed6..eb88fc9c7 100644 --- a/apps/app/src/components/Profile/ProfileFeed/index.tsx +++ b/apps/app/src/components/Profile/ProfileFeed/index.tsx @@ -7,7 +7,13 @@ import { useInfiniteScroll } from '@op/hooks'; import { SkeletonLine } from '@op/ui/Skeleton'; import { useCallback } from 'react'; -import { PostFeed } from '@/components/PostFeed'; +import { + PostFeed, + PostsList, + EmptyPostsState, + DiscussionModalContainer, + usePostFeedActions +} from '@/components/PostFeed'; export const ProfileFeed = ({ profile, @@ -40,6 +46,13 @@ export const ProfileFeed = ({ const allPosts = paginatedData?.pages.flatMap((page) => page.items) || []; + const { + discussionModal, + handleReactionClick, + handleCommentClick, + handleModalClose + } = usePostFeedActions({ slug: profile.profile.slug, limit }); + // Prevent infinite loops. Make sure this is a stable function const stableFetchNextPage = useCallback(() => { fetchNextPage(); @@ -61,7 +74,24 @@ export const ProfileFeed = ({ return (
- + + {allPosts.length > 0 ? ( + + ) : ( + + )} + + + {shouldShowTrigger && (
} diff --git a/apps/app/src/components/screens/LandingScreen/Feed.tsx b/apps/app/src/components/screens/LandingScreen/Feed.tsx index 78f8998c0..c69278dd9 100644 --- a/apps/app/src/components/screens/LandingScreen/Feed.tsx +++ b/apps/app/src/components/screens/LandingScreen/Feed.tsx @@ -2,7 +2,14 @@ import { trpc } from '@op/api/client'; -import { PostFeed, PostFeedSkeleton } from '@/components/PostFeed'; +import { + PostFeed, + PostFeedSkeleton, + PostsList, + EmptyPostsState, + DiscussionModalContainer, + usePostFeedActions +} from '@/components/PostFeed'; export const Feed = () => { const { data: user } = trpc.account.getMyAccount.useQuery(); @@ -12,6 +19,13 @@ export const Feed = () => { error, } = trpc.organization.listAllPosts.useQuery({}); + const { + discussionModal, + handleReactionClick, + handleCommentClick, + handleModalClose + } = usePostFeedActions(); + if (isLoading) { return ; } @@ -31,5 +45,24 @@ export const Feed = () => { return ; } - return ; + return ( + + {postsData.items.length > 0 ? ( + + ) : ( + + )} + + + + ); }; From 585bcbaec3d2bdffe19d062d5ab06e50d8a3152d Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Thu, 24 Jul 2025 14:59:19 +0200 Subject: [PATCH 21/28] Refactor for more composability --- .../src/components/DiscussionModal/index.tsx | 23 +-- apps/app/src/components/PostFeed/index.tsx | 143 +++++++++--------- .../components/Profile/ProfileFeed/index.tsx | 21 ++- .../components/screens/LandingScreen/Feed.tsx | 21 ++- 4 files changed, 109 insertions(+), 99 deletions(-) diff --git a/apps/app/src/components/DiscussionModal/index.tsx b/apps/app/src/components/DiscussionModal/index.tsx index ee3ad2e22..d388665a1 100644 --- a/apps/app/src/components/DiscussionModal/index.tsx +++ b/apps/app/src/components/DiscussionModal/index.tsx @@ -10,7 +10,7 @@ import { LuX } from 'react-icons/lu'; import { PostFeed, - PostsList, + PostItem, usePostFeedActions } from '../PostFeed'; import { PostUpdate } from '../PostUpdate'; @@ -98,8 +98,8 @@ export function DiscussionModal({
{/* Original Post Display */} - ) : comments.length > 0 ? ( - + {comments.map((comment, i) => ( + + ))} ) : (
diff --git a/apps/app/src/components/PostFeed/index.tsx b/apps/app/src/components/PostFeed/index.tsx index 50b3c3a89..5323568a4 100644 --- a/apps/app/src/components/PostFeed/index.tsx +++ b/apps/app/src/components/PostFeed/index.tsx @@ -19,7 +19,7 @@ import { toast } from '@op/ui/Toast'; import { cn } from '@op/ui/utils'; import Image from 'next/image'; import { useFeatureFlagEnabled } from 'posthog-js/react'; -import { Fragment, ReactNode, useState } from 'react'; +import { ReactNode, useState } from 'react'; import { LuEllipsis, LuLeaf } from 'react-icons/lu'; import { Link } from '@/lib/i18n'; @@ -324,89 +324,86 @@ export const EmptyPostsState = () => ( ); -export const PostsList = ({ - posts, +export const PostItem = ({ + postToOrg, user, withLinks, onReactionClick, onCommentClick, }: { - posts: Array; + postToOrg: PostToOrganization; user?: OrganizationUser; withLinks: boolean; onReactionClick: (postId: string, emoji: string) => void; onCommentClick: (post: PostToOrganization) => void; -}) => ( - <> - {posts.map((postToOrg, i) => { - const { organization, post } = postToOrg; - const { urls } = detectLinks(post?.content); - - // For comments (posts without organization), show the post author - // TODO: this is too complex. We need to refactor this - const isComment = !organization; - const displayName = isComment - ? post?.profile?.name - : organization?.profile.name; - const displaySlug = isComment - ? post?.profile?.slug - : organization?.profile.slug; - const avatarData = isComment - ? { profile: post?.profile as Profile } - : organization; - - return ( - - - - - -
- - - - -
- { + const { organization, post } = postToOrg; + const { urls } = detectLinks(post?.content); + + // For comments (posts without organization), show the post author + // TODO: this is too complex. We need to refactor this + const isComment = !organization; + const displayName = isComment + ? post?.profile?.name + : organization?.profile.name; + const displaySlug = isComment + ? post?.profile?.slug + : organization?.profile.slug; + const avatarData = isComment + ? { profile: post?.profile as Profile } + : organization; + + return ( + <> + + + + +
+ + - - - - - -
- - onCommentClick(postToOrg)} - /> -
-
- - -
- - ); - })} - -); +
+ +
+ +
+ + + + +
+ + onCommentClick(postToOrg)} + /> +
+
+
+
+
+ + ); +}; + export const DiscussionModalContainer = ({ discussionModal, diff --git a/apps/app/src/components/Profile/ProfileFeed/index.tsx b/apps/app/src/components/Profile/ProfileFeed/index.tsx index eb88fc9c7..d0daff55e 100644 --- a/apps/app/src/components/Profile/ProfileFeed/index.tsx +++ b/apps/app/src/components/Profile/ProfileFeed/index.tsx @@ -9,7 +9,7 @@ import { useCallback } from 'react'; import { PostFeed, - PostsList, + PostItem, EmptyPostsState, DiscussionModalContainer, usePostFeedActions @@ -76,13 +76,18 @@ export const ProfileFeed = ({
{allPosts.length > 0 ? ( - + <> + {allPosts.map((postToOrg, i) => ( + + ))} + ) : ( )} diff --git a/apps/app/src/components/screens/LandingScreen/Feed.tsx b/apps/app/src/components/screens/LandingScreen/Feed.tsx index c69278dd9..bae07db69 100644 --- a/apps/app/src/components/screens/LandingScreen/Feed.tsx +++ b/apps/app/src/components/screens/LandingScreen/Feed.tsx @@ -5,7 +5,7 @@ import { trpc } from '@op/api/client'; import { PostFeed, PostFeedSkeleton, - PostsList, + PostItem, EmptyPostsState, DiscussionModalContainer, usePostFeedActions @@ -48,13 +48,18 @@ export const Feed = () => { return ( {postsData.items.length > 0 ? ( - + <> + {postsData.items.map((postToOrg, i) => ( + + ))} + ) : ( )} From 1e5b939355dce3614df4075547e6ab00ecda8feb Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Thu, 24 Jul 2025 15:17:39 +0200 Subject: [PATCH 22/28] Allow for better customizing of PostUpdate --- .../src/components/DiscussionModal/index.tsx | 20 +++++++-------- apps/app/src/components/PostFeed/index.tsx | 25 ++++++++----------- apps/app/src/components/PostUpdate/index.tsx | 9 +++++-- .../Profile/ProfileContent/index.tsx | 17 ++++++++++--- .../components/Profile/ProfileFeed/index.tsx | 22 ++++++++-------- .../screens/LandingScreen/index.tsx | 6 ++++- 6 files changed, 57 insertions(+), 42 deletions(-) diff --git a/apps/app/src/components/DiscussionModal/index.tsx b/apps/app/src/components/DiscussionModal/index.tsx index d388665a1..070278520 100644 --- a/apps/app/src/components/DiscussionModal/index.tsx +++ b/apps/app/src/components/DiscussionModal/index.tsx @@ -8,11 +8,9 @@ import { Modal, ModalFooter, ModalHeader } from '@op/ui/Modal'; import { Surface } from '@op/ui/Surface'; import { LuX } from 'react-icons/lu'; -import { - PostFeed, - PostItem, - usePostFeedActions -} from '../PostFeed'; +import { useTranslations } from '@/lib/i18n'; + +import { PostFeed, PostItem, usePostFeedActions } from '../PostFeed'; import { PostUpdate } from '../PostUpdate'; export function DiscussionModal({ @@ -26,12 +24,12 @@ export function DiscussionModal({ }) { const utils = trpc.useUtils(); const { user } = useUser(); + const t = useTranslations(); const { post, organization } = postToOrg; - const { - handleReactionClick, - handleCommentClick - } = usePostFeedActions({ slug: organization?.profile?.slug }); + const { handleReactionClick, handleCommentClick } = usePostFeedActions({ + slug: organization?.profile?.slug, + }); const { data: commentsData, isLoading } = trpc.posts.getPosts.useQuery( { @@ -95,7 +93,7 @@ export function DiscussionModal({
-
+
{/* Original Post Display */} {/* Comments Display */} @@ -138,6 +135,7 @@ export function DiscussionModal({ parentPostId={post.id} placeholder={`Comment${user?.currentProfile?.name ? ` as ${user?.currentProfile?.name}` : ''}...`} onSuccess={handleCommentSuccess} + label={t('Comment')} /> diff --git a/apps/app/src/components/PostFeed/index.tsx b/apps/app/src/components/PostFeed/index.tsx index 5323568a4..625af3f3a 100644 --- a/apps/app/src/components/PostFeed/index.tsx +++ b/apps/app/src/components/PostFeed/index.tsx @@ -252,6 +252,7 @@ const PostCommentButton = ({ onCommentClick: () => void; }) => { const commentsEnabled = useFeatureFlagEnabled('comments'); + if (!commentsEnabled || !post?.id || isComment) return null; return ( @@ -335,7 +336,7 @@ export const PostItem = ({ user?: OrganizationUser; withLinks: boolean; onReactionClick: (postId: string, emoji: string) => void; - onCommentClick: (post: PostToOrganization) => void; + onCommentClick?: (post: PostToOrganization) => void; }) => { const { organization, post } = postToOrg; const { urls } = detectLinks(post?.content); @@ -386,15 +387,14 @@ export const PostItem = ({
- - onCommentClick(postToOrg)} - /> + + {onCommentClick ? ( + onCommentClick(postToOrg)} + /> + ) : null}
@@ -404,7 +404,6 @@ export const PostItem = ({ ); }; - export const DiscussionModalContainer = ({ discussionModal, onClose, @@ -600,9 +599,7 @@ export const PostFeed = ({ className?: string; }) => { return ( -
- {children} -
+
{children}
); }; diff --git a/apps/app/src/components/PostUpdate/index.tsx b/apps/app/src/components/PostUpdate/index.tsx index d22073d60..62b15483e 100644 --- a/apps/app/src/components/PostUpdate/index.tsx +++ b/apps/app/src/components/PostUpdate/index.tsx @@ -51,12 +51,14 @@ const PostUpdateWithUser = ({ parentPostId, placeholder, onSuccess, + label, }: { organization: Organization; className?: string; parentPostId?: string; // If provided, this becomes a comment placeholder?: string; onSuccess?: () => void; + label: string; }) => { const [content, setContent] = useState(''); const [detectedUrls, setDetectedUrls] = useState([]); @@ -107,7 +109,7 @@ const PostUpdateWithUser = ({ setDetectedUrls([]); fileUpload.clearFiles(); setLastFailedPost(null); - + // Call onSuccess callback if provided (for comments) if (onSuccess) { onSuccess(); @@ -310,7 +312,7 @@ const PostUpdateWithUser = ({ } onPress={createNewPostUpdate} > - {createPost.isPending ? : t('Post')} + {createPost.isPending ? : label}
@@ -326,12 +328,14 @@ export const PostUpdate = ({ parentPostId, placeholder, onSuccess, + label, }: { organization?: Organization; className?: string; parentPostId?: string; placeholder?: string; onSuccess?: () => void; + label: string; }) => { const { user } = useUser(); const currentOrg = user?.currentOrganization; @@ -350,6 +354,7 @@ export const PostUpdate = ({ parentPostId={parentPostId} placeholder={placeholder} onSuccess={onSuccess} + label={label} /> ); }; diff --git a/apps/app/src/components/Profile/ProfileContent/index.tsx b/apps/app/src/components/Profile/ProfileContent/index.tsx index c3af971eb..e1881cef3 100644 --- a/apps/app/src/components/Profile/ProfileContent/index.tsx +++ b/apps/app/src/components/Profile/ProfileContent/index.tsx @@ -14,6 +14,8 @@ import Link from 'next/link'; import { ReactNode, Suspense } from 'react'; import { LuCopy, LuGlobe, LuMail } from 'react-icons/lu'; +import { useTranslations } from '@/lib/i18n'; + import { ContactLink } from '@/components/ContactLink'; import ErrorBoundary from '@/components/ErrorBoundary'; import { PostFeedSkeleton } from '@/components/PostFeed'; @@ -243,12 +245,15 @@ export const OrganizationProfileGrid = ({ }: { profile: Organization; }) => { + const t = useTranslations(); + return (
@@ -272,15 +277,21 @@ export const ProfileTabs = ({ children }: { children: React.ReactNode }) => { }; export const ProfileTabsMobile = ({ profile }: { profile: Organization }) => { + const t = useTranslations(); + return ( - Updates - About + {t('Updates')} + {t('About')} }> - + }> diff --git a/apps/app/src/components/Profile/ProfileFeed/index.tsx b/apps/app/src/components/Profile/ProfileFeed/index.tsx index d0daff55e..1cc722d5d 100644 --- a/apps/app/src/components/Profile/ProfileFeed/index.tsx +++ b/apps/app/src/components/Profile/ProfileFeed/index.tsx @@ -7,12 +7,12 @@ import { useInfiniteScroll } from '@op/hooks'; import { SkeletonLine } from '@op/ui/Skeleton'; import { useCallback } from 'react'; -import { - PostFeed, - PostItem, - EmptyPostsState, +import { DiscussionModalContainer, - usePostFeedActions + EmptyPostsState, + PostFeed, + PostItem, + usePostFeedActions, } from '@/components/PostFeed'; export const ProfileFeed = ({ @@ -46,11 +46,11 @@ export const ProfileFeed = ({ const allPosts = paginatedData?.pages.flatMap((page) => page.items) || []; - const { - discussionModal, - handleReactionClick, - handleCommentClick, - handleModalClose + const { + discussionModal, + handleReactionClick, + handleCommentClick, + handleModalClose, } = usePostFeedActions({ slug: profile.profile.slug, limit }); // Prevent infinite loops. Make sure this is a stable function @@ -91,7 +91,7 @@ export const ProfileFeed = ({ ) : ( )} - + { + const t = useTranslations(); + return ( <> }> - +
From 0b1ea5e1505f0fa488148261df3eeba71199888d Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Thu, 24 Jul 2025 15:26:08 +0200 Subject: [PATCH 23/28] add index --- services/db/schema/tables/posts.sql.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/services/db/schema/tables/posts.sql.ts b/services/db/schema/tables/posts.sql.ts index e30537426..5ee8cf4a4 100644 --- a/services/db/schema/tables/posts.sql.ts +++ b/services/db/schema/tables/posts.sql.ts @@ -19,7 +19,11 @@ export const posts = pgTable( profileId: uuid().references(() => profiles.id, { onDelete: 'cascade' }), ...timestamps, }, - (table) => [...serviceRolePolicies, index().on(table.id).concurrently()], + (table) => [ + ...serviceRolePolicies, + index().on(table.id).concurrently(), + index().on(table.parentPostId).concurrently(), + ], ); export const postsToOrganizations = pgTable( From d5e1661cc255c6ebef7442dce6de4f05f63fb2a9 Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Thu, 24 Jul 2025 15:34:00 +0200 Subject: [PATCH 24/28] remove any --- apps/app/src/components/PostFeed/index.tsx | 83 +++++++++++++--------- services/api/src/encoders/posts.ts | 1 + 2 files changed, 52 insertions(+), 32 deletions(-) diff --git a/apps/app/src/components/PostFeed/index.tsx b/apps/app/src/components/PostFeed/index.tsx index 625af3f3a..acb4397b0 100644 --- a/apps/app/src/components/PostFeed/index.tsx +++ b/apps/app/src/components/PostFeed/index.tsx @@ -4,7 +4,13 @@ import { getPublicUrl } from '@/utils'; import { OrganizationUser } from '@/utils/UserProvider'; import { detectLinks, linkifyText } from '@/utils/linkDetection'; import { trpc } from '@op/api/client'; -import type { PostToOrganization, Profile } from '@op/api/encoders'; +import type { + Organization, + Post, + PostAttachment, + PostToOrganization, + Profile, +} from '@op/api/encoders'; import { REACTION_OPTIONS } from '@op/types'; import { AvatarSkeleton } from '@op/ui/Avatar'; import { CommentButton } from '@op/ui/CommentButton'; @@ -143,7 +149,11 @@ const PostContent = ({ content }: { content?: string }) => { return <>{linkifyText(content)}; }; -const PostAttachments = ({ attachments }: { attachments?: any[] }) => { +const PostAttachments = ({ + attachments, +}: { + attachments?: PostAttachment[]; +}) => { if (!attachments) { return null; } @@ -212,7 +222,7 @@ const PostReactions = ({ post, onReactionClick, }: { - post: any; + post: Post; onReactionClick: (postId: string, emoji: string) => void; }) => { if (!post?.id) return null; @@ -247,7 +257,7 @@ const PostCommentButton = ({ isComment, onCommentClick, }: { - post: any; + post: Post; isComment: boolean; onCommentClick: () => void; }) => { @@ -266,17 +276,19 @@ const PostMenu = ({ user, isComment, }: { - organization: any; - post: any; + organization: Organization; + post: Post; user?: OrganizationUser; isComment: boolean; }) => { const canShowMenu = (organization?.id === user?.currentOrganization?.id || (isComment && post?.profile?.id === user?.profile?.id)) && - post?.id; + !!post?.id; - if (!canShowMenu) return null; + if (!canShowMenu) { + return null; + } return ( @@ -291,7 +303,7 @@ const PostMenu = ({ @@ -307,7 +319,9 @@ const PostMenuContent = ({ organizationId: string; canDelete: boolean; }) => { - if (!canDelete) return null; + if (!canDelete) { + return null; + } return ; }; @@ -375,12 +389,14 @@ export const PostItem = ({
- + {organization ? ( + + ) : null} @@ -458,7 +474,7 @@ export const usePostFeedActions = ({ const previousListAllPosts = utils.organization.listAllPosts.getData({}); // Helper function to update post reactions - const updatePostReactions = (item: any) => { + const updatePostReactions = (item: PostToOrganization) => { if (item.post.id === postId) { const currentReaction = item.post.userReaction; const currentCounts = item.post.reactionCounts || {}; @@ -512,24 +528,27 @@ export const usePostFeedActions = ({ // Optimistically update listPosts cache (if slug is provided) if (slug) { - utils.organization.listPosts.setInfiniteData( - { slug, limit }, - (old: any) => { - if (!old) return old; - return { - ...old, - pages: old.pages.map((page: any) => ({ - ...page, - items: page.items.map(updatePostReactions), - })), - }; - }, - ); + utils.organization.listPosts.setInfiniteData({ slug, limit }, (old) => { + if (!old) { + return old; + } + + return { + ...old, + pages: old.pages.map((page) => ({ + ...page, + items: page.items.map(updatePostReactions), + })), + }; + }); } // Optimistically update listAllPosts cache - utils.organization.listAllPosts.setData({}, (old: any) => { - if (!old) return old; + utils.organization.listAllPosts.setData({}, (old) => { + if (!old) { + return old; + } + return { ...old, items: old.items.map(updatePostReactions), diff --git a/services/api/src/encoders/posts.ts b/services/api/src/encoders/posts.ts index abba55bc8..2b9584acc 100644 --- a/services/api/src/encoders/posts.ts +++ b/services/api/src/encoders/posts.ts @@ -32,4 +32,5 @@ export const postsToOrganizationsEncoder = createSelectSchema( organization: organizationsWithProfileEncoder.nullish(), }); +export type PostAttachment = z.infer; export type PostToOrganization = z.infer; From 29b0b06486ee3a3490d75518b94d28cad036c584 Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Thu, 24 Jul 2025 15:58:31 +0200 Subject: [PATCH 25/28] simplifying logic --- .../Onboarding/MatchingOrganizationsForm.tsx | 2 +- .../components/OrganizationAvatar/index.tsx | 8 +- .../src/components/OrganizationList/index.tsx | 8 +- .../components/PendingRelationships/index.tsx | 2 +- apps/app/src/components/PostFeed/index.tsx | 160 ++++-------------- apps/app/src/components/PostUpdate/index.tsx | 4 +- .../ProfileDetails/AddRelationshipModal.tsx | 2 +- .../screens/ProfileOrganizations/index.tsx | 2 +- .../screens/ProfileRelationships/index.tsx | 2 +- 9 files changed, 48 insertions(+), 142 deletions(-) diff --git a/apps/app/src/components/Onboarding/MatchingOrganizationsForm.tsx b/apps/app/src/components/Onboarding/MatchingOrganizationsForm.tsx index cf4be862d..1b9d35b5c 100644 --- a/apps/app/src/components/Onboarding/MatchingOrganizationsForm.tsx +++ b/apps/app/src/components/Onboarding/MatchingOrganizationsForm.tsx @@ -129,7 +129,7 @@ export const MatchingOrganizationsForm = ({ className="hidden" /> diff --git a/apps/app/src/components/OrganizationAvatar/index.tsx b/apps/app/src/components/OrganizationAvatar/index.tsx index 5562ea530..e24942c05 100644 --- a/apps/app/src/components/OrganizationAvatar/index.tsx +++ b/apps/app/src/components/OrganizationAvatar/index.tsx @@ -7,20 +7,18 @@ import Image from 'next/image'; import { Link } from '@/lib/i18n'; export const OrganizationAvatar = ({ - organization, + profile, withLink = true, className, }: { - organization?: { profile?: Profile }; + profile?: Profile; withLink?: boolean; className?: string; }) => { - if (!organization) { + if (!profile) { return null; } - const profile = 'profile' in organization ? organization.profile : null; - const name = profile?.name ?? ''; const avatarImage = profile?.avatarImage; const slug = profile?.slug; diff --git a/apps/app/src/components/OrganizationList/index.tsx b/apps/app/src/components/OrganizationList/index.tsx index f7018041f..0ddf9cd1c 100644 --- a/apps/app/src/components/OrganizationList/index.tsx +++ b/apps/app/src/components/OrganizationList/index.tsx @@ -31,7 +31,7 @@ export const OrganizationList = ({ {organizations?.map((org) => { return (
- +
@@ -145,7 +145,7 @@ export const OrganizationCardList = ({
{relationshipOrg.profile.bio && - relationshipOrg.profile.bio.length > 200 + relationshipOrg.profile.bio.length > 200 ? `${relationshipOrg.profile.bio.slice(0, 200)}...` : relationshipOrg.profile.bio}
@@ -190,7 +190,7 @@ export const OrganizationSummaryList = ({ src={ getPublicUrl( org.profile.avatarImage?.name ?? - org.avatarImage?.name, + org.avatarImage?.name, ) ?? '' } alt={org.profile.name ?? ''} diff --git a/apps/app/src/components/PendingRelationships/index.tsx b/apps/app/src/components/PendingRelationships/index.tsx index aedfe6316..2f19277b5 100644 --- a/apps/app/src/components/PendingRelationships/index.tsx +++ b/apps/app/src/components/PendingRelationships/index.tsx @@ -73,7 +73,7 @@ const PendingRelationshipsSuspense = ({ slug }: { slug: string }) => { className={`flex flex-col justify-between gap-6 border-t p-6 transition-colors sm:flex-row sm:items-center sm:gap-2 ${isAccepted ? 'bg-primary-tealWhite' : ''}`} >
- +
{org.profile.name} diff --git a/apps/app/src/components/PostFeed/index.tsx b/apps/app/src/components/PostFeed/index.tsx index acb4397b0..1e2741604 100644 --- a/apps/app/src/components/PostFeed/index.tsx +++ b/apps/app/src/components/PostFeed/index.tsx @@ -9,7 +9,6 @@ import type { Post, PostAttachment, PostToOrganization, - Profile, } from '@op/api/encoders'; import { REACTION_OPTIONS } from '@op/types'; import { AvatarSkeleton } from '@op/ui/Avatar'; @@ -31,98 +30,24 @@ import { LuEllipsis, LuLeaf } from 'react-icons/lu'; import { Link } from '@/lib/i18n'; import { DiscussionModal } from '../DiscussionModal'; +import { FeedContent, FeedHeader, FeedItem, FeedMain } from '../Feed'; import { LinkPreview } from '../LinkPreview'; import { OrganizationAvatar } from '../OrganizationAvatar'; import { formatRelativeTime } from '../utils'; import { DeletePost } from './DeletePost'; -export const FeedItem = ({ - children, - className, -}: { - children: ReactNode; - className?: string; -}) => { - return
{children}
; -}; - -export const FeedContent = ({ - children, - className, -}: { - children: ReactNode; - className?: string; -}) => { - return ( -
.mediaItem:first-child]:mt-2', - className, - )} - style={{ overflowWrap: 'anywhere' }} - > - {children} -
- ); -}; - -const FeedHeader = ({ - children, - className, -}: { - children: ReactNode; - className?: string; -}) => { - return ( - - {children} - - ); -}; - -export const FeedAvatar = ({ children }: { children?: ReactNode }) => { - return ( -
- {children} -
- ); -}; - -export const FeedMain = ({ - children, - className, - ...props -}: { - children: ReactNode; - className?: string; -} & React.HTMLAttributes) => { - return ( -
- {children} -
- ); -}; - const PostDisplayName = ({ displayName, displaySlug, withLinks, - isComment, }: { displayName?: string; displaySlug?: string; withLinks: boolean; - isComment: boolean; }) => { if (!displayName) return null; - if (withLinks && !isComment) { + if (withLinks) { return {displayName}; } @@ -158,29 +83,25 @@ const PostAttachments = ({ return null; } - return ( - <> - {attachments.map(({ fileName, storageObject }: any) => { - const { mimetype, size } = storageObject.metadata; - - return ( - - - - ); - })} - - ); + return attachments.map(({ fileName, storageObject }: any) => { + const { mimetype, size } = storageObject.metadata; + + return ( + + + + ); + }); }; const AttachmentImage = ({ @@ -254,16 +175,17 @@ const PostReactions = ({ const PostCommentButton = ({ post, - isComment, onCommentClick, }: { post: Post; - isComment: boolean; onCommentClick: () => void; }) => { const commentsEnabled = useFeatureFlagEnabled('comments'); - if (!commentsEnabled || !post?.id || isComment) return null; + // we can disable this to allow for threads in the future + if (!commentsEnabled || !post?.id || post.parentPostId) { + return null; + } return ( @@ -274,16 +196,14 @@ const PostMenu = ({ organization, post, user, - isComment, }: { organization: Organization; post: Post; user?: OrganizationUser; - isComment: boolean; }) => { const canShowMenu = (organization?.id === user?.currentOrganization?.id || - (isComment && post?.profile?.id === user?.profile?.id)) && + post?.profile?.id === user?.profile?.id) && !!post?.id; if (!canShowMenu) { @@ -357,23 +277,18 @@ export const PostItem = ({ // For comments (posts without organization), show the post author // TODO: this is too complex. We need to refactor this - const isComment = !organization; - const displayName = isComment - ? post?.profile?.name - : organization?.profile.name; - const displaySlug = isComment - ? post?.profile?.slug - : organization?.profile.slug; - const avatarData = isComment - ? { profile: post?.profile as Profile } - : organization; + const displayName = + post?.profile?.name ?? organization?.profile.name ?? 'Unkown User'; + const displaySlug = + post?.profile?.slug ?? organization?.profile.slug ?? 'Unkown User'; + const profile = post.profile ?? organization?.profile; return ( <> @@ -384,18 +299,12 @@ export const PostItem = ({ displayName={displayName} displaySlug={displaySlug} withLinks={withLinks} - isComment={isComment} />
{organization ? ( - + ) : null} @@ -407,7 +316,6 @@ export const PostItem = ({ {onCommentClick ? ( onCommentClick(postToOrg)} /> ) : null} diff --git a/apps/app/src/components/PostUpdate/index.tsx b/apps/app/src/components/PostUpdate/index.tsx index 62b15483e..8efd84020 100644 --- a/apps/app/src/components/PostUpdate/index.tsx +++ b/apps/app/src/components/PostUpdate/index.tsx @@ -21,8 +21,8 @@ import { LuImage, LuX } from 'react-icons/lu'; import { useTranslations } from '@/lib/i18n'; +import { FeedItem, FeedMain } from '@/components/Feed'; import { LinkPreview } from '@/components/LinkPreview'; -import { FeedItem, FeedMain } from '@/components/PostFeed'; import { OrganizationAvatar } from '../OrganizationAvatar'; @@ -189,7 +189,7 @@ const PostUpdateWithUser = ({
diff --git a/apps/app/src/components/Profile/ProfileDetails/AddRelationshipModal.tsx b/apps/app/src/components/Profile/ProfileDetails/AddRelationshipModal.tsx index 1eaff1e32..d71f33a94 100644 --- a/apps/app/src/components/Profile/ProfileDetails/AddRelationshipModal.tsx +++ b/apps/app/src/components/Profile/ProfileDetails/AddRelationshipModal.tsx @@ -133,7 +133,7 @@ export const AddRelationshipModalSuspense = ({ <> with diff --git a/apps/app/src/components/screens/ProfileOrganizations/index.tsx b/apps/app/src/components/screens/ProfileOrganizations/index.tsx index 03dbea194..a2d34cab4 100644 --- a/apps/app/src/components/screens/ProfileOrganizations/index.tsx +++ b/apps/app/src/components/screens/ProfileOrganizations/index.tsx @@ -65,7 +65,7 @@ export const OrganizationNameSuspense = ({ slug }: { slug: string }) => { >
- + {organization.profile.name}
diff --git a/apps/app/src/components/screens/ProfileRelationships/index.tsx b/apps/app/src/components/screens/ProfileRelationships/index.tsx index b6e596c5a..aea72cd88 100644 --- a/apps/app/src/components/screens/ProfileRelationships/index.tsx +++ b/apps/app/src/components/screens/ProfileRelationships/index.tsx @@ -53,7 +53,7 @@ const RelationshipList = ({ >
From 61a1a9b1c378413b967bd465a20b1269c9547340 Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Thu, 24 Jul 2025 15:58:48 +0200 Subject: [PATCH 26/28] add Feed directory --- apps/app/src/components/Feed/index.tsx | 75 ++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 apps/app/src/components/Feed/index.tsx diff --git a/apps/app/src/components/Feed/index.tsx b/apps/app/src/components/Feed/index.tsx new file mode 100644 index 000000000..3d9ba6443 --- /dev/null +++ b/apps/app/src/components/Feed/index.tsx @@ -0,0 +1,75 @@ +import { cn } from '@op/ui/utils'; +import { ReactNode } from 'react'; + +export const FeedItem = ({ + children, + className, +}: { + children: ReactNode; + className?: string; +}) => { + return
{children}
; +}; + +export const FeedContent = ({ + children, + className, +}: { + children: ReactNode; + className?: string; +}) => { + return ( +
.mediaItem:first-child]:mt-2', + className, + )} + style={{ overflowWrap: 'anywhere' }} + > + {children} +
+ ); +}; + +export const FeedHeader = ({ + children, + className, +}: { + children: ReactNode; + className?: string; +}) => { + return ( + + {children} + + ); +}; + +export const FeedAvatar = ({ children }: { children?: ReactNode }) => { + return ( +
+ {children} +
+ ); +}; + +export const FeedMain = ({ + children, + className, + ...props +}: { + children: ReactNode; + className?: string; +} & React.HTMLAttributes) => { + return ( +
+ {children} +
+ ); +}; From effb7bf1e7fedc9b12dca95f82fdbfcd00235dd3 Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Thu, 24 Jul 2025 16:21:54 +0200 Subject: [PATCH 27/28] comment style tweaks --- .../src/components/DiscussionModal/index.tsx | 25 ++-- apps/app/src/components/PostFeed/index.tsx | 85 ++++++------ .../components/Profile/ProfileFeed/index.tsx | 9 +- .../components/screens/LandingScreen/Feed.tsx | 33 ++--- .../common/src/services/posts/getPosts.ts | 127 ++++++++++-------- services/api/src/routers/posts/getPosts.ts | 4 +- 6 files changed, 153 insertions(+), 130 deletions(-) diff --git a/apps/app/src/components/DiscussionModal/index.tsx b/apps/app/src/components/DiscussionModal/index.tsx index 070278520..e9894be8d 100644 --- a/apps/app/src/components/DiscussionModal/index.tsx +++ b/apps/app/src/components/DiscussionModal/index.tsx @@ -102,6 +102,7 @@ export function DiscussionModal({ withLinks={false} onReactionClick={handleReactionClick} /> +
{/* Comments Display */} {isLoading ? ( @@ -111,14 +112,20 @@ export function DiscussionModal({ ) : comments.length > 0 ? ( {comments.map((comment, i) => ( - + <> + + {comments.length !== i + 1 && ( +
+ )} + ))}
) : ( @@ -129,7 +136,7 @@ export function DiscussionModal({
{/* Comment Input using PostUpdate */} - + void; onCommentClick?: (post: PostToOrganization) => void; + className?: string; }) => { const { organization, post } = postToOrg; const { urls } = detectLinks(post?.content); @@ -278,53 +280,50 @@ export const PostItem = ({ // For comments (posts without organization), show the post author // TODO: this is too complex. We need to refactor this const displayName = - post?.profile?.name ?? organization?.profile.name ?? 'Unkown User'; + post?.profile?.name ?? organization?.profile.name ?? 'Unknown User'; const displaySlug = - post?.profile?.slug ?? organization?.profile.slug ?? 'Unkown User'; + post?.profile?.slug ?? organization?.profile.slug ?? 'Unknown User'; const profile = post.profile ?? organization?.profile; return ( - <> - - - - -
- - - - -
- {organization ? ( - + + + + +
+ + + + +
+ {organization ? ( + + ) : null} +
+ + + + +
+ + {onCommentClick ? ( + onCommentClick(postToOrg)} + /> ) : null} - - - - - -
- - {onCommentClick ? ( - onCommentClick(postToOrg)} - /> - ) : null} -
-
- - -
- +
+
+
+
); }; @@ -526,7 +525,7 @@ export const PostFeed = ({ className?: string; }) => { return ( -
{children}
+
{children}
); }; diff --git a/apps/app/src/components/Profile/ProfileFeed/index.tsx b/apps/app/src/components/Profile/ProfileFeed/index.tsx index 1cc722d5d..e95fae2fe 100644 --- a/apps/app/src/components/Profile/ProfileFeed/index.tsx +++ b/apps/app/src/components/Profile/ProfileFeed/index.tsx @@ -76,8 +76,8 @@ export const ProfileFeed = ({
{allPosts.length > 0 ? ( - <> - {allPosts.map((postToOrg, i) => ( + allPosts.map((postToOrg, i) => ( + <> - ))} - +
+ + )) ) : ( )} diff --git a/apps/app/src/components/screens/LandingScreen/Feed.tsx b/apps/app/src/components/screens/LandingScreen/Feed.tsx index bae07db69..2b4a6c1df 100644 --- a/apps/app/src/components/screens/LandingScreen/Feed.tsx +++ b/apps/app/src/components/screens/LandingScreen/Feed.tsx @@ -2,13 +2,13 @@ import { trpc } from '@op/api/client'; -import { - PostFeed, - PostFeedSkeleton, - PostItem, - EmptyPostsState, +import { DiscussionModalContainer, - usePostFeedActions + EmptyPostsState, + PostFeed, + PostFeedSkeleton, + PostItem, + usePostFeedActions, } from '@/components/PostFeed'; export const Feed = () => { @@ -19,11 +19,11 @@ export const Feed = () => { error, } = trpc.organization.listAllPosts.useQuery({}); - const { - discussionModal, - handleReactionClick, - handleCommentClick, - handleModalClose + const { + discussionModal, + handleReactionClick, + handleCommentClick, + handleModalClose, } = usePostFeedActions(); if (isLoading) { @@ -48,8 +48,8 @@ export const Feed = () => { return ( {postsData.items.length > 0 ? ( - <> - {postsData.items.map((postToOrg, i) => ( + postsData.items.map((postToOrg, i) => ( + <> { onReactionClick={handleReactionClick} onCommentClick={handleCommentClick} /> - ))} - +
+ + )) ) : ( )} - + { limit = 20, offset = 0, includeChildren = false, - maxDepth = 3, } = input; + let { maxDepth = 3 } = input; + + // enforcing a max depth to prevent infinite cycles + if (maxDepth > 2) { + maxDepth = 2; + } try { // Build where conditions @@ -57,40 +62,44 @@ export const getPosts = async (input: GetPostsInput) => { }, reactions: true, // Recursively include child posts if requested - ...(includeChildren && maxDepth > 0 ? { - childPosts: { - limit: 50, // Reasonable limit for child posts - orderBy: [desc(posts.createdAt)], - with: { - profile: { + ...(includeChildren && maxDepth > 0 + ? { + childPosts: { + limit: 50, // Reasonable limit for child posts + orderBy: [desc(posts.createdAt)], with: { - avatarImage: true, - }, - }, - attachments: { - with: { - storageObject: true, - }, - }, - reactions: true, - // One level of nesting for now (can be expanded recursively) - ...(maxDepth > 1 ? { - childPosts: { - limit: 20, - orderBy: [desc(posts.createdAt)], - with: { - profile: { - with: { - avatarImage: true, - }, + profile: { + with: { + avatarImage: true, }, - reactions: true, }, + attachments: { + with: { + storageObject: true, + }, + }, + reactions: true, + // One level of nesting for now (can be expanded recursively) + ...(maxDepth > 1 + ? { + childPosts: { + limit: 20, + orderBy: [desc(posts.createdAt)], + with: { + profile: { + with: { + avatarImage: true, + }, + }, + reactions: true, + }, + }, + } + : {}), }, - } : {}), - }, - }, - } : {}), + }, + } + : {}), }, }); @@ -113,25 +122,27 @@ export const getPosts = async (input: GetPostsInput) => { }, }, reactions: true, - ...(includeChildren && maxDepth > 0 ? { - childPosts: { - limit: 50, - orderBy: [desc(posts.createdAt)], - with: { - profile: { - with: { - avatarImage: true, - }, - }, - attachments: { + ...(includeChildren && maxDepth > 0 + ? { + childPosts: { + limit: 50, + orderBy: [desc(posts.createdAt)], with: { - storageObject: true, + profile: { + with: { + avatarImage: true, + }, + }, + attachments: { + with: { + storageObject: true, + }, + }, + reactions: true, }, }, - reactions: true, - }, - }, - } : {}), + } + : {}), }, }, organization: { @@ -151,10 +162,11 @@ export const getPosts = async (input: GetPostsInput) => { // Transform to match expected format and add reaction data const actorProfileId = await getCurrentProfileId(); - const itemsWithReactionsAndComments = await getItemsWithReactionsAndComments({ - items: orgPosts, - profileId: actorProfileId, - }); + const itemsWithReactionsAndComments = + await getItemsWithReactionsAndComments({ + items: orgPosts, + profileId: actorProfileId, + }); return itemsWithReactionsAndComments; } @@ -164,14 +176,15 @@ export const getPosts = async (input: GetPostsInput) => { // Add reaction counts and user reactions const actorProfileId = await getCurrentProfileId(); - const itemsWithReactionsAndComments = await getItemsWithReactionsAndComments({ - items: result.map(post => ({ post })), - profileId: actorProfileId, - }); + const itemsWithReactionsAndComments = + await getItemsWithReactionsAndComments({ + items: result.map((post) => ({ post })), + profileId: actorProfileId, + }); - return itemsWithReactionsAndComments.map(item => item.post); + return itemsWithReactionsAndComments.map((item) => item.post); } catch (error) { console.error('Error fetching posts:', error); throw error; } -}; \ No newline at end of file +}; diff --git a/services/api/src/routers/posts/getPosts.ts b/services/api/src/routers/posts/getPosts.ts index 55b8e57f7..1bfe15c2b 100644 --- a/services/api/src/routers/posts/getPosts.ts +++ b/services/api/src/routers/posts/getPosts.ts @@ -6,6 +6,7 @@ import { z } from 'zod'; import { postsEncoder } from '../../encoders'; import withAuthenticated from '../../middlewares/withAuthenticated'; +import withRateLimited from '../../middlewares/withRateLimited'; import { loggedProcedure, router } from '../../trpcFactory'; const meta: OpenApiMeta = { @@ -23,6 +24,7 @@ const outputSchema = z.array(postsEncoder); export const getPosts = router({ getPosts: loggedProcedure + .use(withRateLimited({ windowSize: 10, maxRequests: 10 })) .use(withAuthenticated) .meta(meta) .input(getPostsSchema) @@ -40,4 +42,4 @@ export const getPosts = router({ }); } }), -}); \ No newline at end of file +}); From 31ccd93f04317bd6929e3c79a0b24a08204e9519 Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Thu, 24 Jul 2025 16:27:15 +0200 Subject: [PATCH 28/28] remove any --- apps/app/src/components/PostFeed/index.tsx | 24 +++++++++++----------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/apps/app/src/components/PostFeed/index.tsx b/apps/app/src/components/PostFeed/index.tsx index dad2435d6..c74c84899 100644 --- a/apps/app/src/components/PostFeed/index.tsx +++ b/apps/app/src/components/PostFeed/index.tsx @@ -83,7 +83,7 @@ const PostAttachments = ({ return null; } - return attachments.map(({ fileName, storageObject }: any) => { + return attachments.map(({ fileName, storageObject }) => { const { mimetype, size } = storageObject.metadata; return ( @@ -150,17 +150,17 @@ const PostReactions = ({ const reactions = post.reactionCounts ? Object.entries(post.reactionCounts).map(([reactionType, count]) => { - const reactionOption = REACTION_OPTIONS.find( - (option) => option.key === reactionType, - ); - const emoji = reactionOption?.emoji || reactionType; - - return { - emoji, - count: count as number, - isActive: post.userReaction === reactionType, - }; - }) + const reactionOption = REACTION_OPTIONS.find( + (option) => option.key === reactionType, + ); + const emoji = reactionOption?.emoji || reactionType; + + return { + emoji, + count: count as number, + isActive: post.userReaction === reactionType, + }; + }) : []; return (