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, diff --git a/apps/app/src/components/DiscussionModal/index.tsx b/apps/app/src/components/DiscussionModal/index.tsx new file mode 100644 index 000000000..e9894be8d --- /dev/null +++ b/apps/app/src/components/DiscussionModal/index.tsx @@ -0,0 +1,152 @@ +'use client'; + +import { useUser } from '@/utils/UserProvider'; +import { trpc } from '@op/api/client'; +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 { LuX } from 'react-icons/lu'; + +import { useTranslations } from '@/lib/i18n'; + +import { PostFeed, PostItem, usePostFeedActions } from '../PostFeed'; +import { PostUpdate } from '../PostUpdate'; + +export function DiscussionModal({ + postToOrg, + isOpen, + onClose, +}: { + postToOrg: PostToOrganization; + isOpen: boolean; + onClose: () => void; +}) { + const utils = trpc.useUtils(); + const { user } = useUser(); + const t = useTranslations(); + const { post, organization } = postToOrg; + + const { handleReactionClick, handleCommentClick } = usePostFeedActions({ + slug: organization?.profile?.slug, + }); + + const { data: commentsData, isLoading } = trpc.posts.getPosts.useQuery( + { + parentPostId: post.id, // Get comments (child posts) of this post + limit: 50, + offset: 0, + includeChildren: false, + }, + { enabled: isOpen }, + ); + + const handleCommentSuccess = () => { + utils.posts.getPosts.invalidate({ + parentPostId: post.id, // Invalidate comments for this post + }); + }; + + const sourcePostProfile = post.profile; + + // Get the post author's name for the header + const authorName = sourcePostProfile?.name || 'Unknown'; + + // Transform comments data to match PostFeeds expected PostToOrganizaion 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 */} +
+ {organization?.profile.name}'s Post + +
+ + {/* Mobile header */} +
+ +

{authorName}'s Post

+
{/* Spacer for center alignment */} +
+ + +
+
+ {/* Original Post Display */} + + +
+
+ {/* Comments Display */} + {isLoading ? ( +
+ Loading discussion... +
+ ) : comments.length > 0 ? ( + + {comments.map((comment, i) => ( + <> + + {comments.length !== i + 1 && ( +
+ )} + + ))} +
+ ) : ( +
+ No comments yet. Be the first to comment! +
+ )} +
+ + {/* Comment Input using PostUpdate */} + + + + + +
+ + ); +} 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} +
+ ); +}; 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 2d099274d..e24942c05 100644 --- a/apps/app/src/components/OrganizationAvatar/index.tsx +++ b/apps/app/src/components/OrganizationAvatar/index.tsx @@ -1,32 +1,24 @@ 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, + profile, withLink = true, className, }: { - organization?: relationshipOrganization; + profile?: Profile; withLink?: boolean; className?: string; }) => { - if (!organization) { + if (!profile) { return null; } - // TODO: fix type resolution in drizzle. - 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 5b9248de1..c74c84899 100644 --- a/apps/app/src/components/PostFeed/index.tsx +++ b/apps/app/src/components/PostFeed/index.tsx @@ -4,11 +4,17 @@ 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, + PostAttachment, + PostToOrganization, +} from '@op/api/encoders'; 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 { IconButton } from '@op/ui/IconButton'; import { MediaDisplay } from '@op/ui/MediaDisplay'; import { MenuTrigger } from '@op/ui/Menu'; import { Popover } from '@op/ui/Popover'; @@ -18,142 +24,347 @@ 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 { ReactNode, useState } from 'react'; 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'; -// 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 +const PostDisplayName = ({ + displayName, + displaySlug, + withLinks, +}: { + displayName?: string; + displaySlug?: string; + withLinks: boolean; +}) => { + if (!displayName) return null; - // Future dates handling - if (diff < 0) { - return 'in the future'; + if (withLinks) { + return {displayName}; } - // For very recent times - if (diff < 5) { - return 'just now'; + return <>{displayName}; +}; + +const PostTimestamp = ({ createdAt }: { createdAt?: Date | string | null }) => { + if (!createdAt) { + return null; } - 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 ( + + {formatRelativeTime(createdAt)} + + ); +}; + +const PostContent = ({ content }: { content?: string }) => { + if (!content) { + return null; } - return 'just now'; + return <>{linkifyText(content)}; }; -export const FeedItem = ({ - children, - className, +const PostAttachments = ({ + attachments, }: { - children: ReactNode; - className?: string; + attachments?: PostAttachment[]; }) => { - return
{children}
; + if (!attachments) { + return null; + } + + return attachments.map(({ fileName, storageObject }) => { + const { mimetype, size } = storageObject.metadata; + + return ( + + + + ); + }); }; -export const FeedContent = ({ - children, - className, +const AttachmentImage = ({ + mimetype, + fileName, + storageObjectName, }: { - children: ReactNode; - className?: string; + mimetype: string; + fileName: string; + storageObjectName: string; }) => { + if (!mimetype.startsWith('image/')) return null; + return ( -
.mediaItem:first-child]:mt-2', - className, - )} - style={{ overflowWrap: 'anywhere' }} - > - {children} +
+ {fileName}
); }; -const FeedHeader = ({ - children, - className, +const PostUrls = ({ urls }: { urls: string[] }) => { + if (urls.length === 0) return null; + + return ( +
+ {urls.map((url) => ( + + ))} +
+ ); +}; + +const PostReactions = ({ + post, + onReactionClick, }: { - children: ReactNode; - className?: string; + post: Post; + 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 ( - - {children} - + onReactionClick(post.id!, emoji)} + onAddReaction={(emoji) => onReactionClick(post.id!, emoji)} + /> ); }; -export const FeedAvatar = ({ children }: { children?: ReactNode }) => { +const PostCommentButton = ({ + post, + onCommentClick, +}: { + post: Post; + onCommentClick: () => void; +}) => { + const commentsEnabled = useFeatureFlagEnabled('comments'); + + // we can disable this to allow for threads in the future + if (!commentsEnabled || !post?.id || post.parentPostId) { + return null; + } + return ( -
- {children} -
+ ); }; -export const FeedMain = ({ - children, - className, - ...props +const PostMenu = ({ + organization, + post, + user, }: { - children: ReactNode; - className?: string; -} & React.HTMLAttributes) => { + organization: Organization; + post: Post; + user?: OrganizationUser; +}) => { + const canShowMenu = + (organization?.id === user?.currentOrganization?.id || + post?.profile?.id === user?.profile?.id) && + !!post?.id; + + if (!canShowMenu) { + return null; + } + return ( -
- {children} -
+ + + + + + + + ); }; -export const PostFeed = ({ - posts, +const PostMenuContent = ({ + postId, + organizationId, + canDelete, +}: { + postId: string; + organizationId: string; + canDelete: boolean; +}) => { + if (!canDelete) { + return null; + } + + return ; +}; + +export const EmptyPostsState = () => ( + + + +
+ +
+ {'No posts yet.'} +
+
+
+); + +export const PostItem = ({ + postToOrg, user, + withLinks, + onReactionClick, + onCommentClick, className, - withLinks = true, - slug, - limit = 20, }: { - posts: Array; + postToOrg: PostToOrganization; user?: OrganizationUser; + withLinks: boolean; + onReactionClick: (postId: string, emoji: string) => void; + onCommentClick?: (post: PostToOrganization) => void; className?: string; - withLinks?: boolean; +}) => { + 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 displayName = + post?.profile?.name ?? organization?.profile.name ?? 'Unknown User'; + const displaySlug = + post?.profile?.slug ?? organization?.profile.slug ?? 'Unknown User'; + const profile = post.profile ?? organization?.profile; + + return ( + + + + +
+ + + + +
+ {organization ? ( + + ) : null} +
+ + + + +
+ + {onCommentClick ? ( + onCommentClick(postToOrg)} + /> + ) : null} +
+
+
+
+ ); +}; + +export const DiscussionModalContainer = ({ + discussionModal, + onClose, +}: { + discussionModal: { + isOpen: boolean; + post?: PostToOrganization | null; + }; + onClose: () => void; +}) => { + if (!discussionModal.isOpen || !discussionModal.post) { + return null; + } + + return ( + + ); +}; + +export const usePostFeedActions = ({ + slug, + limit = 20, +}: { slug?: string; limit?: number; -}) => { - const reactionsEnabled = useFeatureFlagEnabled('reactions'); +} = {}) => { const utils = trpc.useUtils(); + const [discussionModal, setDiscussionModal] = useState<{ + isOpen: boolean; + post?: PostToOrganization | null; + }>({ + isOpen: false, + post: null, + }); const toggleReaction = trpc.organization.toggleReaction.useMutation({ onMutate: async ({ postId, reactionType }) => { @@ -170,7 +381,7 @@ export const PostFeed = ({ 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 || {}; @@ -224,24 +435,27 @@ export const PostFeed = ({ // 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), @@ -287,150 +501,31 @@ export const PostFeed = ({ toggleReaction.mutate({ postId, reactionType }); }; + const handleCommentClick = (post: PostToOrganization) => { + setDiscussionModal({ isOpen: true, post }); + }; + + const handleModalClose = () => { + setDiscussionModal({ isOpen: false, post: null }); + }; + + return { + discussionModal, + handleReactionClick, + handleCommentClick, + handleModalClose, + }; +}; + +export const PostFeed = ({ + children, + className, +}: { + children: ReactNode; + className?: string; +}) => { 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.'} -
-
-
- )} -
+
{children}
); }; diff --git a/apps/app/src/components/PostUpdate/index.tsx b/apps/app/src/components/PostUpdate/index.tsx index aef022957..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'; @@ -48,9 +48,17 @@ const TextCounter = ({ text, max }: { text: string; max: number }) => { const PostUpdateWithUser = ({ organization, className, + 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([]); @@ -75,7 +83,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 +109,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 +125,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 +151,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 }); } }; @@ -174,7 +189,7 @@ const PostUpdateWithUser = ({
@@ -183,7 +198,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} @@ -297,7 +312,7 @@ const PostUpdateWithUser = ({ } onPress={createNewPostUpdate} > - {createPost.isPending ? : t('Post')} + {createPost.isPending ? : label}
@@ -310,9 +325,17 @@ const PostUpdateWithUser = ({ export const PostUpdate = ({ organization, className, + parentPostId, + placeholder, + onSuccess, + label, }: { organization?: Organization; className?: string; + parentPostId?: string; + placeholder?: string; + onSuccess?: () => void; + label: string; }) => { const { user } = useUser(); const currentOrg = user?.currentOrganization; @@ -328,6 +351,10 @@ export const PostUpdate = ({ ); }; 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/ProfileDetails/AddRelationshipModal.tsx b/apps/app/src/components/Profile/ProfileDetails/AddRelationshipModal.tsx index 170bc3306..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/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" /> )} diff --git a/apps/app/src/components/Profile/ProfileFeed/index.tsx b/apps/app/src/components/Profile/ProfileFeed/index.tsx index a7371bed6..e95fae2fe 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 { + DiscussionModalContainer, + EmptyPostsState, + PostFeed, + PostItem, + 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,30 @@ export const ProfileFeed = ({ return (
- + + {allPosts.length > 0 ? ( + allPosts.map((postToOrg, i) => ( + <> + +
+ + )) + ) : ( + + )} + + +
{shouldShowTrigger && (
} 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;