}
diff --git a/apps/app/src/components/screens/LandingScreen/Feed.tsx b/apps/app/src/components/screens/LandingScreen/Feed.tsx
index 78f8998c0..2b4a6c1df 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 {
+ DiscussionModalContainer,
+ EmptyPostsState,
+ PostFeed,
+ PostFeedSkeleton,
+ PostItem,
+ 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,30 @@ export const Feed = () => {
return
;
}
- return
;
+ return (
+
+ {postsData.items.length > 0 ? (
+ postsData.items.map((postToOrg, i) => (
+ <>
+
+
+ >
+ ))
+ ) : (
+
+ )}
+
+
+
+ );
};
diff --git a/apps/app/src/components/screens/LandingScreen/index.tsx b/apps/app/src/components/screens/LandingScreen/index.tsx
index bdf6f9d24..7908029e3 100644
--- a/apps/app/src/components/screens/LandingScreen/index.tsx
+++ b/apps/app/src/components/screens/LandingScreen/index.tsx
@@ -7,6 +7,8 @@ import { Tab, TabList, TabPanel, Tabs } from '@op/ui/Tabs';
import { redirect } from 'next/navigation';
import { Suspense } from 'react';
+import { useTranslations } from '@/lib/i18n';
+
import { NewOrganizations } from '@/components/NewOrganizations';
import { NewlyJoinedModal } from '@/components/NewlyJoinedModal';
import { OrganizationHighlights } from '@/components/OrganizationHighlights';
@@ -35,11 +37,13 @@ const LandingScreenFeeds = ({
};
const PostFeed = () => {
+ const t = useTranslations();
+
return (
<>
}>
-
+
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 = ({
>
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';
+};
diff --git a/packages/common/src/services/posts/createPost.ts b/packages/common/src/services/posts/createPost.ts
new file mode 100644
index 000000000..6bc856595
--- /dev/null
+++ b/packages/common/src/services/posts/createPost.ts
@@ -0,0 +1,79 @@
+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 (!newPost) {
+ throw new CommonError('Failed to create post');
+ }
+
+ // 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,
+ reactionCounts: {},
+ userReactions: [],
+ commentCount: 0,
+ };
+ } catch (error) {
+ console.error('Error creating post:', error);
+ throw error;
+ }
+};
diff --git a/packages/common/src/services/posts/getPosts.ts b/packages/common/src/services/posts/getPosts.ts
new file mode 100644
index 000000000..bf8802361
--- /dev/null
+++ b/packages/common/src/services/posts/getPosts.ts
@@ -0,0 +1,190 @@
+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 { getItemsWithReactionsAndComments } 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,
+ } = input;
+ let { maxDepth = 3 } = input;
+
+ // enforcing a max depth to prevent infinite cycles
+ if (maxDepth > 2) {
+ maxDepth = 2;
+ }
+
+ 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 itemsWithReactionsAndComments =
+ await getItemsWithReactionsAndComments({
+ items: orgPosts,
+ profileId: actorProfileId,
+ });
+
+ return itemsWithReactionsAndComments;
+ }
+
+ // Execute query for non-organization posts
+ const result = await query;
+
+ // Add reaction counts and user reactions
+ const actorProfileId = await getCurrentProfileId();
+ const itemsWithReactionsAndComments =
+ await getItemsWithReactionsAndComments({
+ items: result.map((post) => ({ post })),
+ profileId: actorProfileId,
+ });
+
+ return itemsWithReactionsAndComments.map((item) => item.post);
+ } catch (error) {
+ console.error('Error fetching posts:', error);
+ throw error;
+ }
+};
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/common/src/services/posts/listPosts.ts b/packages/common/src/services/posts/listPosts.ts
index 61006cb37..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 {
@@ -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
@@ -100,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,
};
@@ -119,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;
@@ -152,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/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/packages/ui/package.json b/packages/ui/package.json
index b0e030e7e..d17ef5878 100644
--- a/packages/ui/package.json
+++ b/packages/ui/package.json
@@ -17,12 +17,14 @@
"./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",
"./Field": "./src/components/Field.tsx",
"./Form": "./src/components/Form.tsx",
"./Header": "./src/components/Header.tsx",
+ "./IconButton": "./src/components/IconButton.tsx",
"./Keyboard": "./src/components/Keyboard.tsx",
"./ListBox": "./src/components/ListBox.tsx",
"./Menu": "./src/components/Menu.tsx",
diff --git a/packages/ui/src/components/CommentButton.tsx b/packages/ui/src/components/CommentButton.tsx
new file mode 100644
index 000000000..05c40a01b
--- /dev/null
+++ b/packages/ui/src/components/CommentButton.tsx
@@ -0,0 +1,52 @@
+'use client';
+
+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 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: 'h-4 w-4 shrink-0',
+});
+
+// Message Circle Icon SVG
+const MessageCircleIcon = ({ className }: { className?: string }) => (
+
+);
+
+export interface CommentButtonProps
+ extends Omit, 'children'> {
+ count?: number;
+ className?: string;
+}
+
+export const CommentButton = ({
+ count = 0,
+ className,
+ ...props
+}: CommentButtonProps) => {
+ return (
+
+
+ {count} comments
+
+ );
+};
diff --git a/packages/ui/src/components/IconButton.tsx b/packages/ui/src/components/IconButton.tsx
new file mode 100644
index 000000000..f80fb296f
--- /dev/null
+++ b/packages/ui/src/components/IconButton.tsx
@@ -0,0 +1,48 @@
+'use client';
+
+import { Button as RACButton } from 'react-aria-components';
+import { tv } from 'tailwind-variants';
+import type { VariantProps } from 'tailwind-variants';
+
+const iconButtonStyle = tv({
+ base: 'flex items-center justify-center rounded-full outline-none duration-200 focus-visible:outline-2 focus-visible:outline-offset-0 focus-visible:outline-teal-600',
+ variants: {
+ size: {
+ small: 'h-6 w-6 p-1',
+ medium: 'h-8 w-8 p-1',
+ large: 'h-10 w-10 p-2',
+ },
+ variant: {
+ ghost: 'bg-white/80 hover:bg-neutral-gray1 pressed:bg-neutral-gray2',
+ solid: 'bg-neutral-gray1 hover:bg-neutral-gray2 pressed:bg-neutral-gray3',
+ outline: 'border border-neutral-gray1 bg-transparent hover:bg-neutral-gray1 pressed:bg-neutral-gray2',
+ },
+ isDisabled: {
+ true: 'pointer-events-none opacity-30',
+ false: '',
+ },
+ },
+ defaultVariants: {
+ size: 'medium',
+ variant: 'ghost',
+ },
+});
+
+type IconButtonVariants = VariantProps;
+
+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 2447752b2..a9ef676ab 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';
@@ -25,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/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
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
diff --git a/services/api/src/encoders/posts.ts b/services/api/src/encoders/posts.ts
index c8be7f6c6..2b9584acc 100644
--- a/services/api/src/encoders/posts.ts
+++ b/services/api/src/encoders/posts.ts
@@ -3,6 +3,7 @@ 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({
@@ -11,9 +12,14 @@ export const postAttachmentEncoder = createSelectSchema(attachments).extend({
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(),
+ // 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();
@@ -26,4 +32,5 @@ export const postsToOrganizationsEncoder = createSelectSchema(
organization: organizationsWithProfileEncoder.nullish(),
});
+export type PostAttachment = z.infer;
export type PostToOrganization = z.infer;
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/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/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/organization/listRelatedOrganizationPosts.ts b/services/api/src/routers/organization/listRelatedOrganizationPosts.ts
index 03ac354a9..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';
@@ -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,16 +120,21 @@ 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
? 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),
@@ -164,6 +170,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 +192,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,
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..1bfe15c2b
--- /dev/null
+++ b/services/api/src/routers/posts/getPosts.ts
@@ -0,0 +1,45 @@
+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 withRateLimited from '../../middlewares/withRateLimited';
+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(withRateLimited({ windowSize: 10, maxRequests: 10 }))
+ .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',
+ });
+ }
+ }),
+});
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/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/posts.sql.ts b/services/db/schema/tables/posts.sql.ts
index d01742c37..5ee8cf4a4 100644
--- a/services/db/schema/tables/posts.sql.ts
+++ b/services/db/schema/tables/posts.sql.ts
@@ -6,15 +6,24 @@ 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(
'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()],
+ (table) => [
+ ...serviceRolePolicies,
+ index().on(table.id).concurrently(),
+ index().on(table.parentPostId).concurrently(),
+ ],
);
export const postsToOrganizations = pgTable(
@@ -38,11 +47,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(
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;