@tribe-nest/forge 1.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +42 -0
- package/src/client/createForgeClient.ts +54 -0
- package/src/content/ForgeContentProvider.tsx +118 -0
- package/src/content/ForgeEditBridge.tsx +134 -0
- package/src/content/editFlag.ts +23 -0
- package/src/content/index.ts +28 -0
- package/src/content/resolve.ts +67 -0
- package/src/content/types.ts +97 -0
- package/src/content/useField.ts +64 -0
- package/src/contexts/AppAuthContext.tsx +185 -0
- package/src/contexts/AudioPlayerContext.tsx +389 -0
- package/src/contexts/CartContext.tsx +131 -0
- package/src/contexts/PublicAuthContext.tsx +333 -0
- package/src/contexts/useAppAdminGuard.ts +56 -0
- package/src/css.d.ts +5 -0
- package/src/data/collectionParams.ts +57 -0
- package/src/data/queries/useAccount.ts +71 -0
- package/src/data/queries/useAccountSettings.ts +82 -0
- package/src/data/queries/useAnalytics.ts +86 -0
- package/src/data/queries/useAuthActions.ts +28 -0
- package/src/data/queries/useBlog.ts +103 -0
- package/src/data/queries/useBlogComments.ts +96 -0
- package/src/data/queries/useBroadcasts.ts +31 -0
- package/src/data/queries/useCertificates.ts +58 -0
- package/src/data/queries/useChat.ts +267 -0
- package/src/data/queries/useCoachingAvailability.ts +69 -0
- package/src/data/queries/useCoachingProducts.ts +67 -0
- package/src/data/queries/useCohortPage.ts +17 -0
- package/src/data/queries/useCollections.ts +211 -0
- package/src/data/queries/useCommunity.ts +794 -0
- package/src/data/queries/useCoupons.ts +20 -0
- package/src/data/queries/useCourseAccess.ts +50 -0
- package/src/data/queries/useCourses.ts +91 -0
- package/src/data/queries/useCurrencies.ts +33 -0
- package/src/data/queries/useDocuments.ts +208 -0
- package/src/data/queries/useDonations.ts +29 -0
- package/src/data/queries/useEngagement.ts +115 -0
- package/src/data/queries/useEvents.ts +69 -0
- package/src/data/queries/useFinalize.ts +109 -0
- package/src/data/queries/useForms.ts +31 -0
- package/src/data/queries/useInvoice.ts +65 -0
- package/src/data/queries/useLeadMagnet.ts +96 -0
- package/src/data/queries/useMembership.ts +20 -0
- package/src/data/queries/useNotifications.ts +267 -0
- package/src/data/queries/useOffers.ts +56 -0
- package/src/data/queries/useOrders.ts +71 -0
- package/src/data/queries/usePWA.ts +101 -0
- package/src/data/queries/usePageActions.ts +36 -0
- package/src/data/queries/usePaymentFlow.ts +80 -0
- package/src/data/queries/usePaymentLink.ts +47 -0
- package/src/data/queries/usePodcast.ts +85 -0
- package/src/data/queries/usePostCollections.ts +48 -0
- package/src/data/queries/usePosts.ts +94 -0
- package/src/data/queries/useProducts.ts +108 -0
- package/src/data/queries/useReplay.ts +19 -0
- package/src/data/queries/useReviews.ts +122 -0
- package/src/data/queries/useShipping.ts +109 -0
- package/src/data/queries/useSubscriptions.ts +66 -0
- package/src/data/queries/useWebsite.ts +128 -0
- package/src/data/queries/useWebsiteAgent.ts +100 -0
- package/src/index.ts +98 -0
- package/src/provider/ForgeAppProvider.tsx +149 -0
- package/src/provider/ForgeProvider.tsx +91 -0
- package/src/provider/SiteConfigProvider.tsx +27 -0
- package/src/server/_tests/buildPwaHead.spec.ts +182 -0
- package/src/server/_tests/buildWebManifest.spec.ts +313 -0
- package/src/server/_tests/forgeServiceWorkerScript.spec.ts +251 -0
- package/src/server/index.ts +260 -0
- package/src/server/jobs.ts +104 -0
- package/src/server/pwa.ts +332 -0
- package/src/types/index.ts +74 -0
- package/src/types/models.ts +1313 -0
- package/src/ui/analytics/ForgeAnalytics.tsx +208 -0
- package/src/ui/analytics/PageMetaPixel.tsx +92 -0
- package/src/ui/content/Editable.tsx +90 -0
- package/src/ui/format/PriceDisplay.tsx +142 -0
- package/src/ui/format/useFormatCurrency.ts +92 -0
- package/src/ui/headless/agent/useAiAgent.ts +106 -0
- package/src/ui/headless/auth/useLoginFlow.ts +72 -0
- package/src/ui/headless/auth/useSignupForm.ts +85 -0
- package/src/ui/headless/broadcast/useBroadcastWatch.ts +59 -0
- package/src/ui/headless/chat/attachmentKind.ts +27 -0
- package/src/ui/headless/chat/useChatAttachmentUpload.ts +132 -0
- package/src/ui/headless/chat/useChatRoom.ts +223 -0
- package/src/ui/headless/chat/useMessageThread.ts +60 -0
- package/src/ui/headless/checkout/useCheckout.ts +438 -0
- package/src/ui/headless/coaching/useCoachingBooking.ts +223 -0
- package/src/ui/headless/community/index.ts +20 -0
- package/src/ui/headless/community/lexical.ts +131 -0
- package/src/ui/headless/community/replyTree.ts +51 -0
- package/src/ui/headless/community/richLexical.ts +314 -0
- package/src/ui/headless/community/useCommunityAttachmentUpload.ts +137 -0
- package/src/ui/headless/community/useCommunitySpaceFeed.ts +122 -0
- package/src/ui/headless/community/useCommunityThread.ts +95 -0
- package/src/ui/headless/community/useMentionComposer.ts +105 -0
- package/src/ui/headless/community/useRichMentionComposer.ts +193 -0
- package/src/ui/headless/consent/useCookieConsent.ts +81 -0
- package/src/ui/headless/course/useCourseCheckout.ts +99 -0
- package/src/ui/headless/dialog.tsx +14 -0
- package/src/ui/headless/document/index.ts +7 -0
- package/src/ui/headless/document/useContractSignature.ts +68 -0
- package/src/ui/headless/document/useDocumentSigningFlow.ts +92 -0
- package/src/ui/headless/document/useOtpCodeInput.ts +86 -0
- package/src/ui/headless/document/useSignaturePad.ts +180 -0
- package/src/ui/headless/donation/Donation.tsx +115 -0
- package/src/ui/headless/donation/DonationContext.tsx +223 -0
- package/src/ui/headless/donation/fees.ts +15 -0
- package/src/ui/headless/donation/index.ts +10 -0
- package/src/ui/headless/engagement/usePostEngagement.ts +119 -0
- package/src/ui/headless/event/useEventCheckout.ts +141 -0
- package/src/ui/headless/forms/useContactForm.ts +58 -0
- package/src/ui/headless/forms/useEmailListForm.ts +75 -0
- package/src/ui/headless/forms/useSectionedForm.ts +256 -0
- package/src/ui/headless/index.ts +60 -0
- package/src/ui/headless/invoice/useInvoicePayment.ts +48 -0
- package/src/ui/headless/memberHome/index.ts +7 -0
- package/src/ui/headless/memberHome/useMemberHomeNav.ts +82 -0
- package/src/ui/headless/membership/MembershipGate.tsx +39 -0
- package/src/ui/headless/membership/useMembershipCheckout.ts +121 -0
- package/src/ui/headless/offer/Offer.tsx +88 -0
- package/src/ui/headless/offer/OfferContext.tsx +162 -0
- package/src/ui/headless/offer/index.ts +8 -0
- package/src/ui/headless/paymentLink/usePaymentLinkPayment.ts +46 -0
- package/src/ui/headless/podcast/usePodcastPlayer.ts +96 -0
- package/src/ui/headless/posts/index.ts +13 -0
- package/src/ui/headless/posts/upsell.ts +41 -0
- package/src/ui/headless/posts/useMemberPostsFeed.ts +110 -0
- package/src/ui/headless/posts/usePostCollectionDetail.ts +56 -0
- package/src/ui/headless/posts/usePostCollectionsList.ts +90 -0
- package/src/ui/headless/posts/usePostItem.ts +81 -0
- package/src/ui/headless/reviews/index.ts +6 -0
- package/src/ui/headless/reviews/useHelpfulVote.ts +45 -0
- package/src/ui/headless/reviews/useReviewForm.ts +108 -0
- package/src/ui/headless/reviews/useReviewsList.ts +79 -0
- package/src/ui/headless/work/index.ts +25 -0
- package/src/ui/headless/work/useWorkPortal.ts +349 -0
- package/src/ui/index.ts +144 -0
- package/src/ui/payment/ForgePaymentProvider.tsx +45 -0
- package/src/ui/payment/ForgeStripePayment.tsx +171 -0
- package/src/ui/shell/TribeNestApp.tsx +121 -0
- package/src/ui/shell/shellGating.spec.ts +28 -0
- package/src/ui/shell/shellGating.ts +11 -0
- package/src/ui/styled/AccountDashboard.tsx +575 -0
- package/src/ui/styled/AiAgentWidget.tsx +152 -0
- package/src/ui/styled/AudioPlayer.tsx +123 -0
- package/src/ui/styled/BlogCategory.tsx +79 -0
- package/src/ui/styled/BlogComments.tsx +236 -0
- package/src/ui/styled/BlogList.tsx +101 -0
- package/src/ui/styled/BlogPost.tsx +160 -0
- package/src/ui/styled/BlogPostFooter.tsx +33 -0
- package/src/ui/styled/BotProtection.tsx +101 -0
- package/src/ui/styled/Button.tsx +88 -0
- package/src/ui/styled/Cart.tsx +276 -0
- package/src/ui/styled/ChatRoom.tsx +252 -0
- package/src/ui/styled/Checkout.tsx +988 -0
- package/src/ui/styled/CheckoutConfirmation.tsx +452 -0
- package/src/ui/styled/CoachingBooking.tsx +605 -0
- package/src/ui/styled/CoachingConfirmation.tsx +151 -0
- package/src/ui/styled/CoachingDetail.tsx +99 -0
- package/src/ui/styled/CohortPage.tsx +82 -0
- package/src/ui/styled/ConfirmSubscription.tsx +134 -0
- package/src/ui/styled/Confirmation.tsx +171 -0
- package/src/ui/styled/ContactForm.tsx +81 -0
- package/src/ui/styled/CookieConsent.tsx +239 -0
- package/src/ui/styled/CourseAccess.tsx +169 -0
- package/src/ui/styled/CourseCheckout.tsx +425 -0
- package/src/ui/styled/CourseConfirmation.tsx +191 -0
- package/src/ui/styled/CourseDetail.tsx +140 -0
- package/src/ui/styled/CoursesGrid.tsx +85 -0
- package/src/ui/styled/CurrencySwitcher.tsx +51 -0
- package/src/ui/styled/DonationButton.tsx +247 -0
- package/src/ui/styled/DonationPage.tsx +29 -0
- package/src/ui/styled/EmailListForm.tsx +88 -0
- package/src/ui/styled/EventConfirmation.tsx +228 -0
- package/src/ui/styled/EventCountdown.tsx +98 -0
- package/src/ui/styled/EventDetail.tsx +141 -0
- package/src/ui/styled/EventTickets.tsx +548 -0
- package/src/ui/styled/EventsList.tsx +83 -0
- package/src/ui/styled/ForgotPasswordForm.tsx +105 -0
- package/src/ui/styled/FormRenderer.tsx +249 -0
- package/src/ui/styled/InstallBanner.tsx +114 -0
- package/src/ui/styled/InvoiceConfirmation.tsx +50 -0
- package/src/ui/styled/InvoicePayment.tsx +141 -0
- package/src/ui/styled/LeadMagnet.tsx +93 -0
- package/src/ui/styled/Loading.tsx +56 -0
- package/src/ui/styled/LoginForm.tsx +134 -0
- package/src/ui/styled/Markdown.tsx +103 -0
- package/src/ui/styled/MembershipCheckout.tsx +197 -0
- package/src/ui/styled/MembershipTiers.tsx +115 -0
- package/src/ui/styled/OfferButton.tsx +181 -0
- package/src/ui/styled/PageActions.tsx +124 -0
- package/src/ui/styled/PaymentLinkConfirmation.tsx +50 -0
- package/src/ui/styled/PaymentLinkPayment.tsx +85 -0
- package/src/ui/styled/Paywall.tsx +54 -0
- package/src/ui/styled/PlayButton.tsx +48 -0
- package/src/ui/styled/PodcastEpisode.tsx +137 -0
- package/src/ui/styled/PodcastList.tsx +58 -0
- package/src/ui/styled/PodcastShow.tsx +164 -0
- package/src/ui/styled/PostsFeed.tsx +63 -0
- package/src/ui/styled/ProductDetail.tsx +616 -0
- package/src/ui/styled/ProductGrid.tsx +82 -0
- package/src/ui/styled/PushOptIn.tsx +65 -0
- package/src/ui/styled/PwaRegistration.tsx +58 -0
- package/src/ui/styled/ReactionBar.tsx +93 -0
- package/src/ui/styled/ReplayList.tsx +134 -0
- package/src/ui/styled/ResetPasswordForm.tsx +127 -0
- package/src/ui/styled/ReviewForm.tsx +292 -0
- package/src/ui/styled/ReviewsSection.tsx +260 -0
- package/src/ui/styled/SectionedFormRenderer.tsx +146 -0
- package/src/ui/styled/SignupForm.tsx +109 -0
- package/src/ui/styled/UserMenu.tsx +166 -0
- package/src/ui/styled/chat/ChatAttachments.tsx +172 -0
- package/src/ui/styled/chat/ChatComposer.tsx +153 -0
- package/src/ui/styled/chat/ChatThreadPanel.tsx +94 -0
- package/src/ui/styled/chat/MessageBubble.tsx +66 -0
- package/src/ui/styled/chat/NewDmDialog.tsx +78 -0
- package/src/ui/styled/community/CommunityComposer.tsx +358 -0
- package/src/ui/styled/community/CommunityFeed.tsx +483 -0
- package/src/ui/styled/community/CommunityLeaderboard.tsx +109 -0
- package/src/ui/styled/community/CommunityMediaGallery.tsx +116 -0
- package/src/ui/styled/community/CommunityMemberProfile.tsx +285 -0
- package/src/ui/styled/community/CommunityNotifications.tsx +222 -0
- package/src/ui/styled/community/CommunityPostCard.tsx +165 -0
- package/src/ui/styled/community/CommunityPostDetail.tsx +337 -0
- package/src/ui/styled/community/CommunityReactionBar.tsx +107 -0
- package/src/ui/styled/community/CommunityRichText.tsx +157 -0
- package/src/ui/styled/community/CommunitySpaces.tsx +174 -0
- package/src/ui/styled/community/MemberAvatar.tsx +74 -0
- package/src/ui/styled/community/index.ts +23 -0
- package/src/ui/styled/community/util.ts +15 -0
- package/src/ui/styled/members/MemberHomeLayout.tsx +98 -0
- package/src/ui/styled/members/MemberPostCard.tsx +392 -0
- package/src/ui/styled/members/MemberPostsFeed.tsx +219 -0
- package/src/ui/styled/members/PostCollectionDetail.tsx +227 -0
- package/src/ui/styled/members/PostCollectionsGrid.tsx +287 -0
- package/src/ui/styled/members/index.ts +7 -0
- package/src/ui/styled/rich-text.css +150 -0
- package/src/ui/styled/work/WorkInviteAccept.tsx +222 -0
- package/src/ui/styled/work/WorkPortalProjects.tsx +163 -0
- package/src/ui/styled/work/WorkProjectInvoices.tsx +113 -0
- package/src/ui/styled/work/WorkProjectReport.tsx +66 -0
- package/src/ui/styled/work/WorkReportBody.tsx +327 -0
- package/src/ui/styled/work/WorkTaskAttachments.tsx +67 -0
- package/src/ui/styled/work/WorkTaskComments.tsx +151 -0
- package/src/ui/styled/work/WorkTaskDetail.tsx +92 -0
- package/src/ui/styled/work/WorkTokenReport.tsx +49 -0
- package/src/ui/styled/work/index.ts +10 -0
- package/src/ui/theme/ForgeThemeProvider.tsx +121 -0
- package/src/ui/theme/contrast.ts +22 -0
- package/src/utils/analyticsBus.ts +31 -0
- package/src/utils/attribution.ts +68 -0
- package/src/utils/cookieConsent.ts +83 -0
- package/src/utils/landing.ts +153 -0
- package/src/utils/metaPixel.ts +122 -0
- package/src/utils/structuredData.ts +126 -0
|
@@ -0,0 +1,794 @@
|
|
|
1
|
+
import { useInfiniteQuery, useMutation, useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query";
|
|
2
|
+
import { useForge } from "../../provider/ForgeProvider";
|
|
3
|
+
import { useChatSocket } from "./useChat";
|
|
4
|
+
|
|
5
|
+
// ── Types (mirror apps/backend/src/routes/public/community + models) ─────────
|
|
6
|
+
|
|
7
|
+
export type CommunitySpaceType = "feed" | "forum";
|
|
8
|
+
|
|
9
|
+
export interface CommunitySpace {
|
|
10
|
+
id: string;
|
|
11
|
+
profileId: string;
|
|
12
|
+
name: string;
|
|
13
|
+
slug: string | null;
|
|
14
|
+
description: string | null;
|
|
15
|
+
type: CommunitySpaceType;
|
|
16
|
+
courseId: string | null;
|
|
17
|
+
order: number;
|
|
18
|
+
archivedAt: string | null;
|
|
19
|
+
createdAt: string;
|
|
20
|
+
/** Gating tiers — empty array = open to every member. */
|
|
21
|
+
tiers: { id: string; name: string }[];
|
|
22
|
+
/** Only present on the member list query. */
|
|
23
|
+
postCount?: number | null;
|
|
24
|
+
/** Joined members (roster) count. Tiers govern access; join governs belonging. */
|
|
25
|
+
memberCount?: number | null;
|
|
26
|
+
/** Whether the current member has joined this space (new-post notifications). */
|
|
27
|
+
isJoined?: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** One roster row (GET /public/community/spaces/:id/members). */
|
|
31
|
+
export interface CommunitySpaceMemberEntry {
|
|
32
|
+
accountId: string;
|
|
33
|
+
joinedAt: string;
|
|
34
|
+
displayName: string | null;
|
|
35
|
+
avatarUrl: string | null;
|
|
36
|
+
level: number | null;
|
|
37
|
+
role: string | null;
|
|
38
|
+
firstName: string | null;
|
|
39
|
+
lastName: string | null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface CommunitySpaceMembersPage {
|
|
43
|
+
data: CommunitySpaceMemberEntry[];
|
|
44
|
+
total: number;
|
|
45
|
+
page: number;
|
|
46
|
+
pageSize: number;
|
|
47
|
+
hasNextPage: boolean;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface CommunityAuthor {
|
|
51
|
+
accountId: string;
|
|
52
|
+
firstName: string | null;
|
|
53
|
+
lastName: string | null;
|
|
54
|
+
displayName: string | null;
|
|
55
|
+
avatarUrl: string | null;
|
|
56
|
+
level: number | null;
|
|
57
|
+
role: string | null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface CommunityMediaItem {
|
|
61
|
+
id: string;
|
|
62
|
+
url: string;
|
|
63
|
+
type: string;
|
|
64
|
+
size: number | null;
|
|
65
|
+
name: string | null;
|
|
66
|
+
width: number | null;
|
|
67
|
+
height: number | null;
|
|
68
|
+
order: number;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Per-emoji summary row for one entity (reactions are toggled per emoji). */
|
|
72
|
+
export interface ReactionSummary {
|
|
73
|
+
entityId: string;
|
|
74
|
+
emoji: string;
|
|
75
|
+
count: number;
|
|
76
|
+
reactedByMe: boolean;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface CommunityPost {
|
|
80
|
+
id: string;
|
|
81
|
+
profileId: string;
|
|
82
|
+
spaceId: string;
|
|
83
|
+
authorAccountId: string;
|
|
84
|
+
title: string | null;
|
|
85
|
+
/** Lexical editor state (JSON). Render with <CommunityRichText/>. */
|
|
86
|
+
body: unknown;
|
|
87
|
+
bodyText: string | null;
|
|
88
|
+
isPinned: boolean;
|
|
89
|
+
isLocked: boolean;
|
|
90
|
+
replyCount: number;
|
|
91
|
+
reactionCount: number;
|
|
92
|
+
lastActivityAt: string | null;
|
|
93
|
+
createdAt: string;
|
|
94
|
+
author: CommunityAuthor | null;
|
|
95
|
+
media: CommunityMediaItem[];
|
|
96
|
+
reactions?: ReactionSummary[];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface CommunityReply {
|
|
100
|
+
id: string;
|
|
101
|
+
postId: string;
|
|
102
|
+
parentReplyId: string | null;
|
|
103
|
+
authorAccountId: string;
|
|
104
|
+
body: unknown;
|
|
105
|
+
bodyText: string | null;
|
|
106
|
+
reactionCount: number;
|
|
107
|
+
createdAt: string;
|
|
108
|
+
author: CommunityAuthor | null;
|
|
109
|
+
media: CommunityMediaItem[];
|
|
110
|
+
reactions?: ReactionSummary[];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Attachment input shape the community create endpoints accept. */
|
|
114
|
+
export interface CommunityAttachmentInput {
|
|
115
|
+
url: string;
|
|
116
|
+
type: "image" | "video" | "audio" | "document" | "other";
|
|
117
|
+
size: number;
|
|
118
|
+
name?: string;
|
|
119
|
+
width?: number;
|
|
120
|
+
height?: number;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export interface CommunityMemberProfile {
|
|
124
|
+
id: string;
|
|
125
|
+
profileId: string;
|
|
126
|
+
accountId: string;
|
|
127
|
+
displayName: string | null;
|
|
128
|
+
avatarUrl: string | null;
|
|
129
|
+
bio: string | null;
|
|
130
|
+
points: number;
|
|
131
|
+
level: number;
|
|
132
|
+
role: string;
|
|
133
|
+
digestCadence: CommunityDigestCadence;
|
|
134
|
+
lastActiveAt: string | null;
|
|
135
|
+
mutedUntil: string | null;
|
|
136
|
+
createdAt: string;
|
|
137
|
+
firstName: string | null;
|
|
138
|
+
lastName: string | null;
|
|
139
|
+
recentPosts?: {
|
|
140
|
+
id: string;
|
|
141
|
+
spaceId: string;
|
|
142
|
+
title: string | null;
|
|
143
|
+
bodyText: string | null;
|
|
144
|
+
replyCount: number;
|
|
145
|
+
reactionCount: number;
|
|
146
|
+
createdAt: string;
|
|
147
|
+
}[];
|
|
148
|
+
recentReplies?: {
|
|
149
|
+
id: string;
|
|
150
|
+
postId: string;
|
|
151
|
+
spaceId: string;
|
|
152
|
+
bodyText: string | null;
|
|
153
|
+
reactionCount: number;
|
|
154
|
+
createdAt: string;
|
|
155
|
+
postTitle: string | null;
|
|
156
|
+
}[];
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export type CommunityDigestCadence = "off" | "inapp_only" | "hourly" | "daily";
|
|
160
|
+
|
|
161
|
+
export type CommunityNotificationType = "mention" | "reply" | "reaction" | "new_post" | "mod";
|
|
162
|
+
|
|
163
|
+
export interface CommunityNotificationItem {
|
|
164
|
+
id: string;
|
|
165
|
+
profileId: string;
|
|
166
|
+
accountId: string;
|
|
167
|
+
type: CommunityNotificationType | string;
|
|
168
|
+
entityType: "community_post" | "community_reply" | string | null;
|
|
169
|
+
entityId: string | null;
|
|
170
|
+
readAt: string | null;
|
|
171
|
+
createdAt: string;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export interface CommunityLeaderboardEntry {
|
|
175
|
+
accountId: string;
|
|
176
|
+
displayName: string | null;
|
|
177
|
+
avatarUrl: string | null;
|
|
178
|
+
points: number;
|
|
179
|
+
level: number;
|
|
180
|
+
firstName: string | null;
|
|
181
|
+
lastName: string | null;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export interface CommunityPostsPage {
|
|
185
|
+
data: CommunityPost[];
|
|
186
|
+
total: number;
|
|
187
|
+
page: number;
|
|
188
|
+
pageSize: number;
|
|
189
|
+
hasNextPage: boolean;
|
|
190
|
+
nextPage: number | null;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export interface CommunityRepliesPage {
|
|
194
|
+
data: CommunityReply[];
|
|
195
|
+
total: number;
|
|
196
|
+
page: number;
|
|
197
|
+
pageSize: number;
|
|
198
|
+
hasNextPage: boolean;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export interface CommunitySearchResults {
|
|
202
|
+
posts: {
|
|
203
|
+
id: string;
|
|
204
|
+
spaceId: string;
|
|
205
|
+
title: string | null;
|
|
206
|
+
bodyText: string | null;
|
|
207
|
+
authorAccountId: string;
|
|
208
|
+
replyCount: number;
|
|
209
|
+
reactionCount: number;
|
|
210
|
+
createdAt: string;
|
|
211
|
+
rank: number;
|
|
212
|
+
}[];
|
|
213
|
+
replies: {
|
|
214
|
+
id: string;
|
|
215
|
+
postId: string;
|
|
216
|
+
spaceId: string;
|
|
217
|
+
postTitle: string | null;
|
|
218
|
+
bodyText: string | null;
|
|
219
|
+
authorAccountId: string;
|
|
220
|
+
reactionCount: number;
|
|
221
|
+
createdAt: string;
|
|
222
|
+
rank: number;
|
|
223
|
+
}[];
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export type CommunityLeaderboardPeriod = "all" | "7" | "30";
|
|
227
|
+
|
|
228
|
+
/** Display name resolution used everywhere a member is rendered. */
|
|
229
|
+
export function communityMemberName(
|
|
230
|
+
m: { displayName?: string | null; firstName?: string | null; lastName?: string | null } | null | undefined,
|
|
231
|
+
): string {
|
|
232
|
+
if (!m) return "Member";
|
|
233
|
+
return m.displayName || `${m.firstName ?? ""} ${m.lastName ?? ""}`.trim() || "Member";
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Query keys are exported so the realtime layer (and creators) can patch the
|
|
237
|
+
// same caches the data hooks populate — mirrors chatKeys in useChat.ts.
|
|
238
|
+
export const communityKeys = {
|
|
239
|
+
spaces: (profileId?: string) => ["public-community-spaces", profileId] as const,
|
|
240
|
+
spaceMembers: (spaceId?: string) => ["public-community-space-members", spaceId] as const,
|
|
241
|
+
posts: (spaceId?: string) => ["public-community-posts", spaceId] as const,
|
|
242
|
+
post: (postId?: string) => ["public-community-post", postId] as const,
|
|
243
|
+
replies: (postId?: string) => ["public-community-replies", postId] as const,
|
|
244
|
+
myProfile: (profileId?: string) => ["public-community-my-profile", profileId] as const,
|
|
245
|
+
member: (profileId?: string, accountId?: string) => ["public-community-member", profileId, accountId] as const,
|
|
246
|
+
notifications: (profileId?: string, unreadOnly?: boolean) =>
|
|
247
|
+
["public-community-notifications", profileId, unreadOnly] as const,
|
|
248
|
+
unread: (profileId?: string) => ["public-community-unread", profileId] as const,
|
|
249
|
+
leaderboard: (profileId?: string, period?: string) => ["public-community-leaderboard", profileId, period] as const,
|
|
250
|
+
search: (profileId?: string, q?: string, spaceId?: string) =>
|
|
251
|
+
["public-community-search", profileId, q, spaceId ?? null] as const,
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
// ── Data hooks ───────────────────────────────────────────────────────────────
|
|
255
|
+
|
|
256
|
+
/** Spaces the current member can access (GET /public/community/spaces). */
|
|
257
|
+
export function useCommunitySpaces() {
|
|
258
|
+
const { client, profileId } = useForge();
|
|
259
|
+
return useQuery<CommunitySpace[]>({
|
|
260
|
+
queryKey: communityKeys.spaces(profileId),
|
|
261
|
+
queryFn: async () => {
|
|
262
|
+
const res = await client.get("/public/community/spaces", { params: { profileId } });
|
|
263
|
+
return res.data;
|
|
264
|
+
},
|
|
265
|
+
enabled: !!profileId && !!client,
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export interface SpaceMembershipResult {
|
|
270
|
+
isJoined: boolean;
|
|
271
|
+
memberCount: number;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Patch one space's isJoined/memberCount inside the spaces list cache. */
|
|
275
|
+
export function setSpaceMembership(
|
|
276
|
+
queryClient: QueryClient,
|
|
277
|
+
profileId: string | undefined,
|
|
278
|
+
spaceId: string,
|
|
279
|
+
membership: SpaceMembershipResult,
|
|
280
|
+
) {
|
|
281
|
+
queryClient.setQueryData(communityKeys.spaces(profileId), (old: CommunitySpace[] | undefined) =>
|
|
282
|
+
old?.map((s) => (s.id === spaceId ? { ...s, ...membership } : s)),
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Join a space (POST /public/community/spaces/:id/join). Tiers still govern
|
|
288
|
+
* who can access a space — joining opts into new-post notifications/digests
|
|
289
|
+
* and the roster. Interacting (post/reply/react) auto-joins server-side.
|
|
290
|
+
*/
|
|
291
|
+
export function useJoinCommunitySpace() {
|
|
292
|
+
const { client, profileId } = useForge();
|
|
293
|
+
const queryClient = useQueryClient();
|
|
294
|
+
return useMutation({
|
|
295
|
+
mutationFn: async (spaceId: string) => {
|
|
296
|
+
const res = await client.post(`/public/community/spaces/${spaceId}/join`, { profileId });
|
|
297
|
+
return { ...(res.data as SpaceMembershipResult), spaceId };
|
|
298
|
+
},
|
|
299
|
+
onSuccess: ({ spaceId, ...membership }) => {
|
|
300
|
+
setSpaceMembership(queryClient, profileId, spaceId, membership);
|
|
301
|
+
queryClient.invalidateQueries({ queryKey: communityKeys.spaceMembers(spaceId) });
|
|
302
|
+
},
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** Leave a space (POST /public/community/spaces/:id/leave). Always explicit. */
|
|
307
|
+
export function useLeaveCommunitySpace() {
|
|
308
|
+
const { client, profileId } = useForge();
|
|
309
|
+
const queryClient = useQueryClient();
|
|
310
|
+
return useMutation({
|
|
311
|
+
mutationFn: async (spaceId: string) => {
|
|
312
|
+
const res = await client.post(`/public/community/spaces/${spaceId}/leave`, { profileId });
|
|
313
|
+
return { ...(res.data as SpaceMembershipResult), spaceId };
|
|
314
|
+
},
|
|
315
|
+
onSuccess: ({ spaceId, ...membership }) => {
|
|
316
|
+
setSpaceMembership(queryClient, profileId, spaceId, membership);
|
|
317
|
+
queryClient.invalidateQueries({ queryKey: communityKeys.spaceMembers(spaceId) });
|
|
318
|
+
},
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** Paginated joined-member roster for a space. */
|
|
323
|
+
export function useCommunitySpaceMembers(spaceId?: string, limit = 50) {
|
|
324
|
+
const { client, profileId } = useForge();
|
|
325
|
+
return useInfiniteQuery<CommunitySpaceMembersPage>({
|
|
326
|
+
queryKey: communityKeys.spaceMembers(spaceId),
|
|
327
|
+
queryFn: async ({ pageParam = 1 }) => {
|
|
328
|
+
const res = await client.get(`/public/community/spaces/${spaceId}/members`, {
|
|
329
|
+
params: { profileId, page: pageParam, limit },
|
|
330
|
+
});
|
|
331
|
+
return res.data;
|
|
332
|
+
},
|
|
333
|
+
initialPageParam: 1,
|
|
334
|
+
getNextPageParam: (lastPage) => (lastPage.hasNextPage ? lastPage.page + 1 : undefined),
|
|
335
|
+
enabled: !!spaceId && !!client && !!profileId,
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** Paginated posts for a space, pinned first then most recent activity. */
|
|
340
|
+
export function useCommunitySpacePosts(spaceId?: string, limit = 20) {
|
|
341
|
+
const { client, profileId } = useForge();
|
|
342
|
+
return useInfiniteQuery<CommunityPostsPage>({
|
|
343
|
+
queryKey: communityKeys.posts(spaceId),
|
|
344
|
+
queryFn: async ({ pageParam = 1 }) => {
|
|
345
|
+
const res = await client.get(`/public/community/spaces/${spaceId}/posts`, {
|
|
346
|
+
params: { profileId, page: pageParam, limit },
|
|
347
|
+
});
|
|
348
|
+
return res.data;
|
|
349
|
+
},
|
|
350
|
+
initialPageParam: 1,
|
|
351
|
+
getNextPageParam: (lastPage) => (lastPage.hasNextPage ? lastPage.page + 1 : undefined),
|
|
352
|
+
enabled: !!spaceId && !!client && !!profileId,
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* A single post (GET /public/community/posts/:id). The endpoint also returns a
|
|
358
|
+
* replies page — we request the minimum (limit=1) and let the dedicated
|
|
359
|
+
* replies hook own pagination, so both caches stay simple.
|
|
360
|
+
*/
|
|
361
|
+
export function useCommunityPost(postId?: string) {
|
|
362
|
+
const { client, profileId } = useForge();
|
|
363
|
+
return useQuery<CommunityPost>({
|
|
364
|
+
queryKey: communityKeys.post(postId),
|
|
365
|
+
queryFn: async () => {
|
|
366
|
+
const res = await client.get(`/public/community/posts/${postId}`, {
|
|
367
|
+
params: { profileId, page: 1, limit: 1 },
|
|
368
|
+
});
|
|
369
|
+
return res.data.post;
|
|
370
|
+
},
|
|
371
|
+
enabled: !!postId && !!client && !!profileId,
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Flat, chronological reply pages for a post (GET /public/community/posts/:id/replies).
|
|
377
|
+
* `parentReplyId` is included on every row — assemble the tree with
|
|
378
|
+
* `buildReplyTree` (data nesting is unlimited; the 3-level display cap is a UI concern).
|
|
379
|
+
*/
|
|
380
|
+
export function useCommunityReplies(postId?: string, limit = 100) {
|
|
381
|
+
const { client, profileId } = useForge();
|
|
382
|
+
return useInfiniteQuery<CommunityRepliesPage>({
|
|
383
|
+
queryKey: communityKeys.replies(postId),
|
|
384
|
+
queryFn: async ({ pageParam = 1 }) => {
|
|
385
|
+
const res = await client.get(`/public/community/posts/${postId}/replies`, {
|
|
386
|
+
params: { profileId, page: pageParam, limit },
|
|
387
|
+
});
|
|
388
|
+
return res.data;
|
|
389
|
+
},
|
|
390
|
+
initialPageParam: 1,
|
|
391
|
+
getNextPageParam: (lastPage) => (lastPage.hasNextPage ? lastPage.page + 1 : undefined),
|
|
392
|
+
enabled: !!postId && !!client && !!profileId,
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
export interface CreateCommunityPostInput {
|
|
397
|
+
title?: string;
|
|
398
|
+
/** Lexical editor state — build one from plain text with `buildLexicalState`. */
|
|
399
|
+
body: unknown;
|
|
400
|
+
attachments?: CommunityAttachmentInput[];
|
|
401
|
+
mentionedAccountIds?: string[];
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** Create a post in a space (POST /public/community/spaces/:id/posts). */
|
|
405
|
+
export function useCreateCommunityPost(spaceId?: string) {
|
|
406
|
+
const { client, profileId } = useForge();
|
|
407
|
+
const queryClient = useQueryClient();
|
|
408
|
+
return useMutation({
|
|
409
|
+
mutationFn: async (input: CreateCommunityPostInput) => {
|
|
410
|
+
const res = await client.post(`/public/community/spaces/${spaceId}/posts`, { profileId, ...input });
|
|
411
|
+
return res.data as CommunityPost;
|
|
412
|
+
},
|
|
413
|
+
onSuccess: (post) => {
|
|
414
|
+
prependPostToFeed(queryClient, spaceId, post);
|
|
415
|
+
queryClient.invalidateQueries({ queryKey: communityKeys.spaces(profileId) });
|
|
416
|
+
},
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
export interface CreateCommunityReplyInput {
|
|
421
|
+
body: unknown;
|
|
422
|
+
parentReplyId?: string;
|
|
423
|
+
attachments?: CommunityAttachmentInput[];
|
|
424
|
+
mentionedAccountIds?: string[];
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** Reply to a post — optionally nested under another reply. */
|
|
428
|
+
export function useCreateCommunityReply(postId?: string) {
|
|
429
|
+
const { client, profileId } = useForge();
|
|
430
|
+
const queryClient = useQueryClient();
|
|
431
|
+
return useMutation({
|
|
432
|
+
mutationFn: async (input: CreateCommunityReplyInput) => {
|
|
433
|
+
const res = await client.post(`/public/community/posts/${postId}/replies`, { profileId, ...input });
|
|
434
|
+
return res.data as CommunityReply;
|
|
435
|
+
},
|
|
436
|
+
onSuccess: (reply) => {
|
|
437
|
+
appendReplyToThread(queryClient, postId, reply);
|
|
438
|
+
// Replying implicitly joins the post's space server-side.
|
|
439
|
+
queryClient.invalidateQueries({ queryKey: communityKeys.spaces(profileId) });
|
|
440
|
+
},
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
export type ReactableEntityType = "community_post" | "community_reply" | "chat_message";
|
|
445
|
+
|
|
446
|
+
export interface ToggleReactionResult {
|
|
447
|
+
reacted: boolean;
|
|
448
|
+
emoji: string;
|
|
449
|
+
/** Authoritative per-emoji summary for the entity after the toggle. */
|
|
450
|
+
reactions: ReactionSummary[];
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/** Toggle an emoji reaction (POST /public/community/:entityType/:id/react). */
|
|
454
|
+
export function useToggleCommunityReaction() {
|
|
455
|
+
const { client, profileId } = useForge();
|
|
456
|
+
const queryClient = useQueryClient();
|
|
457
|
+
return useMutation({
|
|
458
|
+
mutationFn: async (input: {
|
|
459
|
+
entityType: ReactableEntityType;
|
|
460
|
+
entityId: string;
|
|
461
|
+
emoji: string;
|
|
462
|
+
/** Cache hints so the summary lands in the right query data. */
|
|
463
|
+
spaceId?: string;
|
|
464
|
+
postId?: string;
|
|
465
|
+
}) => {
|
|
466
|
+
const res = await client.post(`/public/community/${input.entityType}/${input.entityId}/react`, {
|
|
467
|
+
profileId,
|
|
468
|
+
emoji: input.emoji,
|
|
469
|
+
});
|
|
470
|
+
return { ...(res.data as ToggleReactionResult), input };
|
|
471
|
+
},
|
|
472
|
+
onSuccess: ({ reactions, input }) => {
|
|
473
|
+
setReactionSummary(queryClient, input, reactions);
|
|
474
|
+
// Adding a reaction implicitly joins the space server-side.
|
|
475
|
+
queryClient.invalidateQueries({ queryKey: communityKeys.spaces(profileId) });
|
|
476
|
+
},
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/** Report a post or reply to the moderators. */
|
|
481
|
+
export function useReportCommunityContent() {
|
|
482
|
+
const { client, profileId } = useForge();
|
|
483
|
+
return useMutation({
|
|
484
|
+
mutationFn: async (input: {
|
|
485
|
+
entityId: string;
|
|
486
|
+
entityType: "community_post" | "community_reply";
|
|
487
|
+
reason?: string;
|
|
488
|
+
}) => {
|
|
489
|
+
const res = await client.post(`/public/community/posts/${input.entityId}/report`, {
|
|
490
|
+
profileId,
|
|
491
|
+
entityType: input.entityType,
|
|
492
|
+
reason: input.reason,
|
|
493
|
+
});
|
|
494
|
+
return res.data;
|
|
495
|
+
},
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/** The member's own community profile — created on first read (GET /public/community/me). */
|
|
500
|
+
export function useMyCommunityProfile(enabled = true) {
|
|
501
|
+
const { client, profileId } = useForge();
|
|
502
|
+
return useQuery<CommunityMemberProfile>({
|
|
503
|
+
queryKey: communityKeys.myProfile(profileId),
|
|
504
|
+
queryFn: async () => {
|
|
505
|
+
const res = await client.get("/public/community/me", { params: { profileId } });
|
|
506
|
+
return res.data;
|
|
507
|
+
},
|
|
508
|
+
enabled: enabled && !!profileId && !!client,
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/** Update display name / bio / avatar / digest cadence (PUT /public/community/me). */
|
|
513
|
+
export function useUpdateMyCommunityProfile() {
|
|
514
|
+
const { client, profileId } = useForge();
|
|
515
|
+
const queryClient = useQueryClient();
|
|
516
|
+
return useMutation({
|
|
517
|
+
mutationFn: async (input: {
|
|
518
|
+
displayName?: string | null;
|
|
519
|
+
bio?: string | null;
|
|
520
|
+
avatarUrl?: string | null;
|
|
521
|
+
digestCadence?: CommunityDigestCadence;
|
|
522
|
+
}) => {
|
|
523
|
+
const res = await client.put("/public/community/me", { profileId, ...input });
|
|
524
|
+
return res.data as CommunityMemberProfile;
|
|
525
|
+
},
|
|
526
|
+
onSuccess: (profile) => {
|
|
527
|
+
queryClient.setQueryData(communityKeys.myProfile(profileId), profile);
|
|
528
|
+
queryClient.invalidateQueries({ queryKey: communityKeys.member(profileId, profile?.accountId) });
|
|
529
|
+
},
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/** Another member's profile + recent activity (filtered to spaces you can read). */
|
|
534
|
+
export function useCommunityMemberProfile(accountId?: string) {
|
|
535
|
+
const { client, profileId } = useForge();
|
|
536
|
+
return useQuery<CommunityMemberProfile>({
|
|
537
|
+
queryKey: communityKeys.member(profileId, accountId),
|
|
538
|
+
queryFn: async () => {
|
|
539
|
+
const res = await client.get(`/public/community/profile/${accountId}`, { params: { profileId } });
|
|
540
|
+
return res.data;
|
|
541
|
+
},
|
|
542
|
+
enabled: !!accountId && !!profileId && !!client,
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/** In-app inbox (GET /public/community/notifications). Returns { data, unreadCount }. */
|
|
547
|
+
export function useCommunityNotifications({ unreadOnly = false, limit = 50 } = {}) {
|
|
548
|
+
const { client, profileId } = useForge();
|
|
549
|
+
return useQuery<{ data: CommunityNotificationItem[]; unreadCount: number }>({
|
|
550
|
+
queryKey: communityKeys.notifications(profileId, unreadOnly),
|
|
551
|
+
queryFn: async () => {
|
|
552
|
+
const res = await client.get("/public/community/notifications", {
|
|
553
|
+
params: { profileId, page: 1, limit, unreadOnly },
|
|
554
|
+
});
|
|
555
|
+
return res.data;
|
|
556
|
+
},
|
|
557
|
+
enabled: !!profileId && !!client,
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/** Unread count for the bell, polled every 30s (same cadence as chat unread counts). */
|
|
562
|
+
export function useCommunityUnreadCount(enabled = true) {
|
|
563
|
+
const { client, profileId } = useForge();
|
|
564
|
+
return useQuery<number>({
|
|
565
|
+
queryKey: communityKeys.unread(profileId),
|
|
566
|
+
queryFn: async () => {
|
|
567
|
+
const res = await client.get("/public/community/notifications", {
|
|
568
|
+
params: { profileId, page: 1, limit: 1, unreadOnly: true },
|
|
569
|
+
});
|
|
570
|
+
return res.data.unreadCount as number;
|
|
571
|
+
},
|
|
572
|
+
enabled: enabled && !!profileId && !!client,
|
|
573
|
+
refetchInterval: 30000,
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/** Mark notifications read — omit ids to mark ALL unread as read. */
|
|
578
|
+
export function useMarkCommunityNotificationsRead() {
|
|
579
|
+
const { client, profileId } = useForge();
|
|
580
|
+
const queryClient = useQueryClient();
|
|
581
|
+
return useMutation({
|
|
582
|
+
mutationFn: async (ids?: string[]) => {
|
|
583
|
+
await client.post("/public/community/notifications/mark-read", { profileId, ids });
|
|
584
|
+
},
|
|
585
|
+
onSuccess: () => {
|
|
586
|
+
queryClient.invalidateQueries({ queryKey: ["public-community-notifications"] });
|
|
587
|
+
queryClient.invalidateQueries({ queryKey: communityKeys.unread(profileId) });
|
|
588
|
+
},
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/** Points leaderboard (GET /public/community/leaderboard?period=all|7|30). */
|
|
593
|
+
export function useCommunityLeaderboard(period: CommunityLeaderboardPeriod = "all", limit = 20) {
|
|
594
|
+
const { client, profileId } = useForge();
|
|
595
|
+
return useQuery<CommunityLeaderboardEntry[]>({
|
|
596
|
+
queryKey: communityKeys.leaderboard(profileId, period),
|
|
597
|
+
queryFn: async () => {
|
|
598
|
+
const res = await client.get("/public/community/leaderboard", { params: { profileId, period, limit } });
|
|
599
|
+
return res.data;
|
|
600
|
+
},
|
|
601
|
+
enabled: !!profileId && !!client,
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* Member directory for @mention autocomplete. There is no dedicated public
|
|
607
|
+
* member-search endpoint yet, so this reads the all-time leaderboard at its max
|
|
608
|
+
* page size (100) — every community_profiles row ranked by points — and filters
|
|
609
|
+
* client-side.
|
|
610
|
+
*/
|
|
611
|
+
export function useCommunityMentionDirectory() {
|
|
612
|
+
const { client, profileId } = useForge();
|
|
613
|
+
return useQuery<CommunityLeaderboardEntry[]>({
|
|
614
|
+
queryKey: communityKeys.leaderboard(profileId, "mention-directory"),
|
|
615
|
+
queryFn: async () => {
|
|
616
|
+
const res = await client.get("/public/community/leaderboard", {
|
|
617
|
+
params: { profileId, period: "all", limit: 100 },
|
|
618
|
+
});
|
|
619
|
+
return res.data;
|
|
620
|
+
},
|
|
621
|
+
enabled: !!profileId && !!client,
|
|
622
|
+
staleTime: 60_000,
|
|
623
|
+
});
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/**
|
|
627
|
+
* FTS over posts + replies (min 2 chars). Scopes to spaces the member can read;
|
|
628
|
+
* pass `spaceId` (a space uuid OR its per-profile slug) to scope to one space —
|
|
629
|
+
* scoping can only narrow, never widen, access. Backward-compatible: called with
|
|
630
|
+
* just a query it searches every accessible space (prior behavior).
|
|
631
|
+
*/
|
|
632
|
+
export function useCommunitySearch(q: string, spaceId?: string) {
|
|
633
|
+
const { client, profileId } = useForge();
|
|
634
|
+
const trimmed = q.trim();
|
|
635
|
+
return useQuery<CommunitySearchResults>({
|
|
636
|
+
queryKey: communityKeys.search(profileId, trimmed, spaceId),
|
|
637
|
+
queryFn: async () => {
|
|
638
|
+
const res = await client.get("/public/community/search", {
|
|
639
|
+
params: { profileId, q: trimmed, page: 1, limit: 20, ...(spaceId ? { spaceId } : {}) },
|
|
640
|
+
});
|
|
641
|
+
return res.data;
|
|
642
|
+
},
|
|
643
|
+
enabled: trimmed.length >= 2 && !!profileId && !!client,
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// ── Realtime primitive ───────────────────────────────────────────────────────
|
|
648
|
+
|
|
649
|
+
/**
|
|
650
|
+
* Community realtime rides the same Socket.io gateway as chat (one connection
|
|
651
|
+
* per site). Emits `communityJoinSpace` / `communityLeaveSpace`; receives a
|
|
652
|
+
* `message` envelope with `event` in {communityNewPost, communityNewReply,
|
|
653
|
+
* communityReaction}.
|
|
654
|
+
*/
|
|
655
|
+
export const useCommunitySocket = useChatSocket;
|
|
656
|
+
|
|
657
|
+
export interface CommunityReactionEvent {
|
|
658
|
+
profileId: string;
|
|
659
|
+
spaceId: string | null;
|
|
660
|
+
entityType: ReactableEntityType;
|
|
661
|
+
entityId: string;
|
|
662
|
+
accountId: string;
|
|
663
|
+
emoji: string;
|
|
664
|
+
action: "added" | "removed";
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// ── Cache patch helpers (shared by mutations + the realtime layer) ───────────
|
|
668
|
+
|
|
669
|
+
/** Prepend a post to the first page of a space feed cache (dedupes by id). */
|
|
670
|
+
export function prependPostToFeed(queryClient: QueryClient, spaceId: string | undefined, post: CommunityPost) {
|
|
671
|
+
if (!spaceId) return;
|
|
672
|
+
queryClient.setQueryData(communityKeys.posts(spaceId), (old: any) => {
|
|
673
|
+
if (!old?.pages?.length) return old;
|
|
674
|
+
const [first, ...rest] = old.pages;
|
|
675
|
+
if (first.data.some((p: CommunityPost) => p.id === post.id)) return old;
|
|
676
|
+
return {
|
|
677
|
+
...old,
|
|
678
|
+
pages: [{ ...first, data: [{ reactions: [], ...post }, ...first.data], total: first.total + 1 }, ...rest],
|
|
679
|
+
};
|
|
680
|
+
});
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/** Append a reply to the last page of a post's replies cache + bump counts. */
|
|
684
|
+
export function appendReplyToThread(queryClient: QueryClient, postId: string | undefined, reply: CommunityReply) {
|
|
685
|
+
if (!postId) return;
|
|
686
|
+
queryClient.setQueryData(communityKeys.replies(postId), (old: any) => {
|
|
687
|
+
if (!old?.pages?.length) return old;
|
|
688
|
+
const pages = [...old.pages];
|
|
689
|
+
const lastIdx = pages.length - 1;
|
|
690
|
+
const last = pages[lastIdx];
|
|
691
|
+
if (pages.some((p: any) => p.data.some((r: CommunityReply) => r.id === reply.id))) return old;
|
|
692
|
+
pages[lastIdx] = { ...last, data: [...last.data, { reactions: [], ...reply }], total: last.total + 1 };
|
|
693
|
+
return { ...old, pages };
|
|
694
|
+
});
|
|
695
|
+
queryClient.setQueryData(communityKeys.post(postId), (old: CommunityPost | undefined) =>
|
|
696
|
+
old ? { ...old, replyCount: (old.replyCount ?? 0) + 1, lastActivityAt: reply.createdAt } : old,
|
|
697
|
+
);
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/** Bump a post's replyCount inside a space feed cache (realtime new-reply event). */
|
|
701
|
+
export function bumpFeedReplyCount(queryClient: QueryClient, spaceId: string | undefined, postId: string) {
|
|
702
|
+
if (!spaceId) return;
|
|
703
|
+
queryClient.setQueryData(communityKeys.posts(spaceId), (old: any) => {
|
|
704
|
+
if (!old?.pages?.length) return old;
|
|
705
|
+
return {
|
|
706
|
+
...old,
|
|
707
|
+
pages: old.pages.map((page: any) => ({
|
|
708
|
+
...page,
|
|
709
|
+
data: page.data.map((p: CommunityPost) =>
|
|
710
|
+
p.id === postId ? { ...p, replyCount: (p.replyCount ?? 0) + 1 } : p,
|
|
711
|
+
),
|
|
712
|
+
})),
|
|
713
|
+
};
|
|
714
|
+
});
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/** Replace an entity's reaction summary in every cache that renders it. */
|
|
718
|
+
export function setReactionSummary(
|
|
719
|
+
queryClient: QueryClient,
|
|
720
|
+
target: { entityType: ReactableEntityType; entityId: string; spaceId?: string; postId?: string },
|
|
721
|
+
reactions: ReactionSummary[],
|
|
722
|
+
) {
|
|
723
|
+
const patch = <T extends { id: string; reactions?: ReactionSummary[]; reactionCount?: number }>(item: T): T =>
|
|
724
|
+
item.id === target.entityId
|
|
725
|
+
? { ...item, reactions, reactionCount: reactions.reduce((n, r) => n + r.count, 0) }
|
|
726
|
+
: item;
|
|
727
|
+
|
|
728
|
+
if (target.entityType === "community_post") {
|
|
729
|
+
if (target.spaceId) {
|
|
730
|
+
queryClient.setQueryData(communityKeys.posts(target.spaceId), (old: any) => {
|
|
731
|
+
if (!old?.pages?.length) return old;
|
|
732
|
+
return { ...old, pages: old.pages.map((page: any) => ({ ...page, data: page.data.map(patch) })) };
|
|
733
|
+
});
|
|
734
|
+
}
|
|
735
|
+
queryClient.setQueryData(communityKeys.post(target.entityId), (old: CommunityPost | undefined) =>
|
|
736
|
+
old ? patch(old) : old,
|
|
737
|
+
);
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
if (target.entityType === "community_reply" && target.postId) {
|
|
741
|
+
queryClient.setQueryData(communityKeys.replies(target.postId), (old: any) => {
|
|
742
|
+
if (!old?.pages?.length) return old;
|
|
743
|
+
return { ...old, pages: old.pages.map((page: any) => ({ ...page, data: page.data.map(patch) })) };
|
|
744
|
+
});
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
/**
|
|
749
|
+
* Apply a realtime reaction delta from another member. Our own toggles are
|
|
750
|
+
* already covered by the authoritative summary in the mutation response.
|
|
751
|
+
*/
|
|
752
|
+
export function applyReactionDelta(
|
|
753
|
+
queryClient: QueryClient,
|
|
754
|
+
event: CommunityReactionEvent,
|
|
755
|
+
{ postId }: { postId?: string } = {},
|
|
756
|
+
) {
|
|
757
|
+
const delta = event.action === "added" ? 1 : -1;
|
|
758
|
+
|
|
759
|
+
const patch = <T extends { id: string; reactions?: ReactionSummary[]; reactionCount?: number }>(item: T): T => {
|
|
760
|
+
if (item.id !== event.entityId) return item;
|
|
761
|
+
const existing = item.reactions ?? [];
|
|
762
|
+
const row = existing.find((r) => r.emoji === event.emoji);
|
|
763
|
+
let next: ReactionSummary[];
|
|
764
|
+
if (row) {
|
|
765
|
+
next = existing
|
|
766
|
+
.map((r) => (r.emoji === event.emoji ? { ...r, count: Math.max(0, r.count + delta) } : r))
|
|
767
|
+
.filter((r) => r.count > 0);
|
|
768
|
+
} else if (delta > 0) {
|
|
769
|
+
next = [...existing, { entityId: event.entityId, emoji: event.emoji, count: 1, reactedByMe: false }];
|
|
770
|
+
} else {
|
|
771
|
+
next = existing;
|
|
772
|
+
}
|
|
773
|
+
return { ...item, reactions: next, reactionCount: Math.max(0, (item.reactionCount ?? 0) + delta) };
|
|
774
|
+
};
|
|
775
|
+
|
|
776
|
+
if (event.entityType === "community_post") {
|
|
777
|
+
if (event.spaceId) {
|
|
778
|
+
queryClient.setQueryData(communityKeys.posts(event.spaceId), (old: any) => {
|
|
779
|
+
if (!old?.pages?.length) return old;
|
|
780
|
+
return { ...old, pages: old.pages.map((page: any) => ({ ...page, data: page.data.map(patch) })) };
|
|
781
|
+
});
|
|
782
|
+
}
|
|
783
|
+
queryClient.setQueryData(communityKeys.post(event.entityId), (old: CommunityPost | undefined) =>
|
|
784
|
+
old ? patch(old) : old,
|
|
785
|
+
);
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
if (event.entityType === "community_reply" && postId) {
|
|
789
|
+
queryClient.setQueryData(communityKeys.replies(postId), (old: any) => {
|
|
790
|
+
if (!old?.pages?.length) return old;
|
|
791
|
+
return { ...old, pages: old.pages.map((page: any) => ({ ...page, data: page.data.map(patch) })) };
|
|
792
|
+
});
|
|
793
|
+
}
|
|
794
|
+
}
|