@tribe-nest/forge 3.59.0 → 3.61.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.
Files changed (266) hide show
  1. package/package.json +2 -1
  2. package/src/_tests/testCommunity.tsx +28 -0
  3. package/src/contexts/CartContext.tsx +34 -1
  4. package/src/contexts/CommunityContext.tsx +192 -0
  5. package/src/contexts/PublicAuthContext.tsx +15 -3
  6. package/src/data/queries/_tests/metaSignals.spec.tsx +130 -0
  7. package/src/data/queries/useAccount.ts +33 -2
  8. package/src/data/queries/useAnalytics.ts +6 -6
  9. package/src/data/queries/useCallChat.ts +2 -2
  10. package/src/data/queries/useChat.ts +52 -40
  11. package/src/data/queries/useCheckouts.ts +2 -1
  12. package/src/data/queries/useCoachingAvailability.ts +10 -1
  13. package/src/data/queries/useCommunities.ts +345 -0
  14. package/src/data/queries/useCommunity.ts +450 -102
  15. package/src/data/queries/useCommunityAgent.ts +247 -0
  16. package/src/data/queries/useCommunityCalendar.ts +150 -0
  17. package/src/data/queries/useCommunityClassroom.ts +75 -0
  18. package/src/data/queries/useCommunityMembers.ts +108 -0
  19. package/src/data/queries/useCommunityModeration.ts +327 -0
  20. package/src/data/queries/useCommunityWiki.ts +137 -0
  21. package/src/data/queries/useCourses.ts +2 -0
  22. package/src/data/queries/useEmailSignIn.ts +79 -0
  23. package/src/data/queries/useEvents.ts +6 -1
  24. package/src/data/queries/useForms.ts +5 -1
  25. package/src/data/queries/useLeadMagnet.ts +6 -2
  26. package/src/data/queries/useMembership.ts +32 -7
  27. package/src/data/queries/useMembershipCalls.ts +116 -7
  28. package/src/data/queries/useMyBookings.ts +24 -1
  29. package/src/data/queries/useMyTickets.ts +24 -1
  30. package/src/data/queries/useNotifications.ts +6 -2
  31. package/src/data/queries/useOrders.ts +2 -1
  32. package/src/data/queries/usePWA.ts +111 -35
  33. package/src/data/queries/usePostCollections.ts +28 -16
  34. package/src/data/queries/usePosts.ts +16 -14
  35. package/src/data/queries/useSiteApp.ts +130 -0
  36. package/src/data/queries/useSubscriptions.ts +28 -9
  37. package/src/i18n/de.json +891 -2
  38. package/src/i18n/en.json +891 -2
  39. package/src/index.ts +36 -7
  40. package/src/runtime/pages.spec.ts +36 -0
  41. package/src/runtime/pages.ts +52 -27
  42. package/src/server/_tests/buildWebManifest.spec.ts +1 -1
  43. package/src/server/_tests/forgeServiceWorkerScript.spec.ts +4 -4
  44. package/src/types/models.ts +11 -0
  45. package/src/ui/analytics/ForgeAnalytics.tsx +64 -34
  46. package/src/ui/analytics/PageMetaPixel.tsx +23 -8
  47. package/src/ui/analytics/_tests/ForgeAnalytics.meta.spec.tsx +129 -0
  48. package/src/ui/analytics/_tests/metaCommerceEvents.spec.tsx +159 -0
  49. package/src/ui/analytics/useMetaEvents.ts +78 -0
  50. package/src/ui/format/_tests/membershipGate.spec.ts +6 -6
  51. package/src/ui/format/membershipGate.ts +19 -15
  52. package/src/ui/headless/_tests/communityAgent.spec.tsx +519 -0
  53. package/src/ui/headless/_tests/communityCalendar.spec.tsx +195 -0
  54. package/src/ui/headless/_tests/communityClassroom.spec.tsx +120 -0
  55. package/src/ui/headless/_tests/communityGate.spec.tsx +80 -0
  56. package/src/ui/headless/_tests/communityHarness.tsx +212 -0
  57. package/src/ui/headless/_tests/communityMembers.spec.tsx +316 -0
  58. package/src/ui/headless/_tests/communityPoll.spec.tsx +389 -0
  59. package/src/ui/headless/_tests/communityProvider.spec.tsx +151 -0
  60. package/src/ui/headless/_tests/memberHomeBar.spec.ts +84 -0
  61. package/src/ui/headless/_tests/siteAppBar.spec.ts +83 -0
  62. package/src/ui/headless/_tests/siteAppHarness.ts +19 -0
  63. package/src/ui/headless/_tests/siteAppHome.spec.ts +226 -0
  64. package/src/ui/headless/_tests/siteAppPrompts.spec.ts +185 -0
  65. package/src/ui/headless/_tests/useCommunityInvite.spec.tsx +104 -0
  66. package/src/ui/headless/_tests/useCommunityJoin.spec.tsx +596 -0
  67. package/src/ui/headless/_tests/useCommunityMembership.spec.tsx +179 -0
  68. package/src/ui/headless/_tests/useCommunityModeration.spec.tsx +420 -0
  69. package/src/ui/headless/_tests/useCommunityReads.spec.tsx +295 -0
  70. package/src/ui/headless/_tests/useCommunityUpgrade.spec.tsx +157 -0
  71. package/src/ui/headless/_tests/useCommunityWiki.spec.tsx +224 -0
  72. package/src/ui/headless/_tests/useSiteApp.spec.tsx +52 -0
  73. package/src/ui/headless/_tests/wikiMediaImage.spec.tsx +132 -0
  74. package/src/ui/headless/auth/useEmailCodeSignIn.ts +186 -0
  75. package/src/ui/headless/auth/useSignupForm.ts +30 -3
  76. package/src/ui/headless/chat/useChatAttachmentUpload.ts +9 -6
  77. package/src/ui/headless/chat/useChatRoom.ts +35 -1
  78. package/src/ui/headless/communities/CommunityGate.tsx +67 -0
  79. package/src/ui/headless/communities/index.ts +96 -0
  80. package/src/ui/headless/communities/paths.ts +36 -0
  81. package/src/ui/headless/communities/useCommunity.ts +140 -0
  82. package/src/ui/headless/communities/useCommunityAbout.ts +71 -0
  83. package/src/ui/headless/communities/useCommunityCalendar.ts +156 -0
  84. package/src/ui/headless/communities/useCommunityClassroom.ts +84 -0
  85. package/src/ui/headless/communities/useCommunityHome.ts +91 -0
  86. package/src/ui/headless/communities/useCommunityInvite.ts +116 -0
  87. package/src/ui/headless/communities/useCommunityJoin.ts +494 -0
  88. package/src/ui/headless/communities/useCommunityMembership.ts +182 -0
  89. package/src/ui/headless/communities/useCommunityUpgrade.ts +213 -0
  90. package/src/ui/headless/communities/useCommunityWiki.ts +266 -0
  91. package/src/ui/headless/communities/useMyMemberships.ts +59 -0
  92. package/src/ui/headless/community/communityPoll.ts +297 -0
  93. package/src/ui/headless/community/countries.ts +123 -0
  94. package/src/ui/headless/community/index.ts +81 -1
  95. package/src/ui/headless/community/linkPreview.ts +71 -0
  96. package/src/ui/headless/community/richLexical.ts +27 -2
  97. package/src/ui/headless/community/useCommunityAgent.ts +285 -0
  98. package/src/ui/headless/community/useCommunityAttachmentUpload.ts +7 -3
  99. package/src/ui/headless/community/useCommunityMembers.ts +175 -0
  100. package/src/ui/headless/community/useCommunityModeration.ts +300 -0
  101. package/src/ui/headless/community/useCommunitySpaceFeed.ts +22 -4
  102. package/src/ui/headless/community/useCommunityThread.ts +16 -3
  103. package/src/ui/headless/community/useMentionComposer.ts +17 -9
  104. package/src/ui/headless/community/useRichMentionComposer.ts +21 -5
  105. package/src/ui/headless/downloads/index.ts +8 -0
  106. package/src/ui/headless/downloads/useDownloadPage.ts +134 -0
  107. package/src/ui/headless/index.ts +21 -4
  108. package/src/ui/headless/memberHome/index.ts +11 -1
  109. package/src/ui/headless/memberHome/memberHomeBar.ts +91 -0
  110. package/src/ui/headless/memberHome/useMemberHomeNav.ts +84 -44
  111. package/src/ui/headless/membership/_tests/membershipCheckoutRefetch.spec.tsx +8 -0
  112. package/src/ui/headless/membership/_tests/useMembershipCheckoutApplication.spec.tsx +5 -4
  113. package/src/ui/headless/membership/_tests/useMembershipCheckoutQuestionnaire.spec.tsx +1 -1
  114. package/src/ui/headless/membership/_tests/useMembershipCheckoutTrial.spec.tsx +1 -1
  115. package/src/ui/headless/membership/useMembershipCheckout.ts +89 -22
  116. package/src/ui/headless/membership/useMembershipGateNotice.ts +25 -7
  117. package/src/ui/headless/portal/index.ts +57 -0
  118. package/src/ui/headless/portal/usePortal.ts +274 -0
  119. package/src/ui/headless/portal/usePortalFiles.ts +68 -0
  120. package/src/ui/headless/portal/usePortalMessages.ts +189 -0
  121. package/src/ui/headless/portal/usePortalPreview.ts +142 -0
  122. package/src/ui/headless/posts/upsell.ts +54 -8
  123. package/src/ui/headless/posts/useMemberPostsFeed.ts +6 -5
  124. package/src/ui/headless/posts/usePostCollectionDetail.ts +7 -4
  125. package/src/ui/headless/posts/usePostCollectionsList.ts +6 -5
  126. package/src/ui/headless/posts/usePostItem.ts +6 -5
  127. package/src/ui/headless/siteApp/appBar.ts +102 -0
  128. package/src/ui/headless/siteApp/appHome.ts +344 -0
  129. package/src/ui/headless/siteApp/appPrompts.ts +229 -0
  130. package/src/ui/headless/siteApp/index.ts +58 -0
  131. package/src/ui/headless/siteApp/useAppHome.ts +210 -0
  132. package/src/ui/headless/siteApp/useAppInstallRequest.ts +16 -0
  133. package/src/ui/headless/work/index.ts +2 -0
  134. package/src/ui/headless/work/useWorkPortal.ts +37 -26
  135. package/src/ui/index.ts +28 -12
  136. package/src/ui/styled/AccountDashboard.tsx +754 -602
  137. package/src/ui/styled/AiAgentWidget.tsx +13 -1
  138. package/src/ui/styled/ChatRoom.tsx +126 -16
  139. package/src/ui/styled/Checkout.tsx +8 -0
  140. package/src/ui/styled/CheckoutConfirmation.tsx +8 -0
  141. package/src/ui/styled/CoachingConfirmation.tsx +8 -0
  142. package/src/ui/styled/CoachingDetail.tsx +2 -0
  143. package/src/ui/styled/CourseConfirmation.tsx +5 -0
  144. package/src/ui/styled/CourseDetail.tsx +2 -0
  145. package/src/ui/styled/DownloadPage.tsx +263 -0
  146. package/src/ui/styled/EventConfirmation.tsx +10 -0
  147. package/src/ui/styled/EventDetail.tsx +10 -0
  148. package/src/ui/styled/FilmLibrary.tsx +22 -9
  149. package/src/ui/styled/MembershipCheckout.tsx +37 -17
  150. package/src/ui/styled/MembershipTierCallout.tsx +20 -1
  151. package/src/ui/styled/MembershipTiers.tsx +49 -11
  152. package/src/ui/styled/MusicLinkPage.tsx +5 -5
  153. package/src/ui/styled/Paywall.tsx +1 -1
  154. package/src/ui/styled/ProductDetail.tsx +12 -0
  155. package/src/ui/styled/PwaRegistration.tsx +5 -1
  156. package/src/ui/styled/SignupForm.tsx +18 -3
  157. package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +6 -6
  158. package/src/ui/styled/_tests/AccountDashboardMemberships.spec.tsx +151 -0
  159. package/src/ui/styled/_tests/AccountDashboardRentals.spec.tsx +1 -1
  160. package/src/ui/styled/_tests/AccountNotifications.spec.tsx +135 -0
  161. package/src/ui/styled/_tests/CourseCallsSection.spec.tsx +7 -1
  162. package/src/ui/styled/_tests/DownloadPage.spec.tsx +68 -0
  163. package/src/ui/styled/_tests/MembershipCheckout.spec.tsx +54 -11
  164. package/src/ui/styled/_tests/membershipTierCallout.spec.tsx +1 -1
  165. package/src/ui/styled/_tests/membershipTiersCuratedAccess.spec.tsx +1 -1
  166. package/src/ui/styled/app/AppBar.tsx +298 -0
  167. package/src/ui/styled/app/AppHome.tsx +1139 -0
  168. package/src/ui/styled/app/AppHomeMark.tsx +66 -0
  169. package/src/ui/styled/app/AppPrompts.tsx +464 -0
  170. package/src/ui/styled/app/_tests/AppBar.spec.tsx +96 -0
  171. package/src/ui/styled/app/_tests/AppHome.spec.tsx +209 -0
  172. package/src/ui/styled/app/_tests/AppPrompts.spec.tsx +232 -0
  173. package/src/ui/styled/app/index.ts +7 -0
  174. package/src/ui/styled/chat/ChatThreadPanel.tsx +29 -4
  175. package/src/ui/styled/chat/MessageBubble.tsx +43 -6
  176. package/src/ui/styled/communities/CommunitiesList.tsx +88 -0
  177. package/src/ui/styled/communities/CommunityAbout.tsx +145 -0
  178. package/src/ui/styled/communities/CommunityCalendar.tsx +484 -0
  179. package/src/ui/styled/communities/CommunityClassroom.tsx +302 -0
  180. package/src/ui/styled/communities/CommunityHome.tsx +298 -0
  181. package/src/ui/styled/communities/CommunityJoinSheet.tsx +544 -0
  182. package/src/ui/styled/communities/CommunityMembershipMenu.tsx +249 -0
  183. package/src/ui/styled/communities/CommunityPending.tsx +49 -0
  184. package/src/ui/styled/communities/CommunityPublicWiki.tsx +75 -0
  185. package/src/ui/styled/communities/CommunityUpgradeSheet.tsx +137 -0
  186. package/src/ui/styled/communities/CommunityWiki.tsx +289 -0
  187. package/src/ui/styled/communities/MyMembershipsList.tsx +115 -0
  188. package/src/ui/styled/communities/_tests/CommunityAppHome.spec.tsx +92 -0
  189. package/src/ui/styled/communities/_tests/CommunityClassroomCalendar.spec.tsx +453 -0
  190. package/src/ui/styled/communities/_tests/CommunityMobile.spec.tsx +399 -0
  191. package/src/ui/styled/communities/_tests/CommunityPages.spec.tsx +102 -0
  192. package/src/ui/styled/communities/_tests/CommunityWiki.spec.tsx +223 -0
  193. package/src/ui/styled/communities/index.ts +19 -0
  194. package/src/ui/styled/communities/shared.tsx +177 -0
  195. package/src/ui/styled/community/CommunityAgent.tsx +286 -0
  196. package/src/ui/styled/community/CommunityComposer.tsx +266 -15
  197. package/src/ui/styled/community/CommunityFeed.tsx +50 -1
  198. package/src/ui/styled/community/CommunityLinkPreview.tsx +159 -0
  199. package/src/ui/styled/community/CommunityMemberGlobe.tsx +171 -0
  200. package/src/ui/styled/community/CommunityMemberProfile.tsx +137 -4
  201. package/src/ui/styled/community/CommunityMembers.tsx +387 -0
  202. package/src/ui/styled/community/CommunityModeration.tsx +587 -0
  203. package/src/ui/styled/community/CommunityModerationState.tsx +127 -0
  204. package/src/ui/styled/community/CommunityModeratorMenu.tsx +317 -0
  205. package/src/ui/styled/community/CommunityNotifications.tsx +52 -6
  206. package/src/ui/styled/community/CommunityPollCard.tsx +250 -0
  207. package/src/ui/styled/community/CommunityPostCard.tsx +110 -34
  208. package/src/ui/styled/community/CommunityPostDetail.tsx +157 -71
  209. package/src/ui/styled/community/CommunityRichText.tsx +177 -32
  210. package/src/ui/styled/community/CommunitySpaces.tsx +49 -14
  211. package/src/ui/styled/community/_tests/CommunityAgent.spec.tsx +571 -0
  212. package/src/ui/styled/community/_tests/CommunityComposer.spec.tsx +4 -1
  213. package/src/ui/styled/community/_tests/CommunityLinkPreview.spec.tsx +167 -0
  214. package/src/ui/styled/community/_tests/CommunityMembers.spec.tsx +137 -0
  215. package/src/ui/styled/community/_tests/CommunityModeration.spec.tsx +291 -0
  216. package/src/ui/styled/community/_tests/CommunityPoll.spec.tsx +386 -0
  217. package/src/ui/styled/community/_tests/CommunityPostDetail.spec.tsx +4 -1
  218. package/src/ui/styled/community/index.ts +37 -2
  219. package/src/ui/styled/forge-utilities.css +253 -0
  220. package/src/ui/styled/members/CallRecordingActions.tsx +182 -0
  221. package/src/ui/styled/members/CallTranscriptViewer.tsx +424 -0
  222. package/src/ui/styled/members/MemberCallsCalendar.tsx +41 -54
  223. package/src/ui/styled/members/MemberHomeLayout.tsx +327 -29
  224. package/src/ui/styled/members/MemberPostCard.tsx +9 -5
  225. package/src/ui/styled/members/MemberPostsFeed.tsx +3 -2
  226. package/src/ui/styled/members/PostCollectionDetail.tsx +18 -10
  227. package/src/ui/styled/members/PostCollectionsGrid.tsx +24 -10
  228. package/src/ui/styled/members/_tests/MemberCallsCalendar.spec.tsx +167 -8
  229. package/src/ui/styled/members/callTranscriptText.ts +67 -0
  230. package/src/ui/styled/members/index.ts +8 -0
  231. package/src/ui/styled/portal/PortalAccountSection.tsx +61 -0
  232. package/src/ui/styled/portal/PortalActivity.tsx +167 -0
  233. package/src/ui/styled/portal/PortalBilling.tsx +197 -0
  234. package/src/ui/styled/portal/PortalFiles.tsx +279 -0
  235. package/src/ui/styled/portal/PortalHome.tsx +401 -0
  236. package/src/ui/styled/portal/PortalMessages.tsx +300 -0
  237. package/src/ui/styled/portal/PortalPreviewBanner.tsx +75 -0
  238. package/src/ui/styled/portal/PortalProjects.tsx +407 -0
  239. package/src/ui/styled/portal/PortalShell.tsx +397 -0
  240. package/src/ui/styled/portal/_tests/PortalAppHome.spec.tsx +90 -0
  241. package/src/ui/styled/portal/_tests/PortalFiles.spec.tsx +159 -0
  242. package/src/ui/styled/portal/_tests/PortalHome.spec.tsx +148 -0
  243. package/src/ui/styled/portal/_tests/PortalMessages.spec.tsx +168 -0
  244. package/src/ui/styled/portal/_tests/PortalPreviewBanner.spec.tsx +132 -0
  245. package/src/ui/styled/portal/_tests/portalNav.spec.ts +123 -0
  246. package/src/ui/styled/portal/index.ts +13 -0
  247. package/src/ui/styled/portal/portalNav.ts +260 -0
  248. package/src/ui/styled/portal/shared.tsx +206 -0
  249. package/src/ui/styled/work/WorkInviteAccept.tsx +228 -168
  250. package/src/ui/styled/work/WorkPortalProjects.tsx +68 -120
  251. package/src/ui/styled/work/WorkProjectDocuments.tsx +99 -128
  252. package/src/ui/styled/work/WorkProjectInvoices.tsx +91 -86
  253. package/src/ui/styled/work/WorkProjectReport.tsx +11 -17
  254. package/src/ui/styled/work/WorkReportBody.tsx +223 -248
  255. package/src/ui/styled/work/WorkTaskAttachments.tsx +23 -29
  256. package/src/ui/styled/work/WorkTaskComments.tsx +75 -102
  257. package/src/ui/styled/work/WorkTaskDetail.tsx +48 -59
  258. package/src/ui/styled/work/WorkTokenReport.tsx +8 -16
  259. package/src/ui/styled/work/index.ts +1 -1
  260. package/src/utils/_tests/metaEvents.spec.ts +264 -0
  261. package/src/utils/_tests/safeRedirect.spec.ts +2 -2
  262. package/src/utils/landing.ts +19 -6
  263. package/src/utils/metaEvents.ts +302 -0
  264. package/src/ui/headless/membership/MembershipGate.tsx +0 -44
  265. package/src/ui/styled/_tests/AccountDashboardCommunity.spec.tsx +0 -149
  266. package/src/ui/styled/_tests/AccountDashboardTrial.spec.tsx +0 -105
@@ -1,6 +1,15 @@
1
- import { useInfiniteQuery, useMutation, useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query";
2
- import { useForge } from "../../provider/ForgeProvider";
1
+ import { useEffect, useState } from "react";
2
+ import {
3
+ keepPreviousData,
4
+ useInfiniteQuery,
5
+ useMutation,
6
+ useQuery,
7
+ useQueryClient,
8
+ type QueryClient,
9
+ } from "@tanstack/react-query";
10
+ import { useCommunityScope } from "../../contexts/CommunityContext";
3
11
  import { useChatSocket } from "./useChat";
12
+ import type { CommunityAgentAuthor } from "./useCommunityAgent";
4
13
 
5
14
  // ── Types (mirror apps/backend/src/routes/public/community + models) ─────────
6
15
 
@@ -17,7 +26,7 @@ export interface CommunitySpace {
17
26
  order: number;
18
27
  archivedAt: string | null;
19
28
  createdAt: string;
20
- /** Gating tiers — empty array = open to every member. */
29
+ /** Gating tiers: empty array = open to every member. */
21
30
  tiers: { id: string; name: string }[];
22
31
  /** Only present on the member list query. */
23
32
  postCount?: number | null;
@@ -25,6 +34,14 @@ export interface CommunitySpace {
25
34
  memberCount?: number | null;
26
35
  /** Whether the current member has joined this space (new-post notifications). */
27
36
  isJoined?: boolean;
37
+ /** The level a member needs to read this space (1 = no level needed). */
38
+ minLevel?: number;
39
+ /**
40
+ * On the member list: the space is above the member's level. It is listed so
41
+ * they can see what their next level opens, but its feed stays closed (the
42
+ * server refuses it too).
43
+ */
44
+ levelLocked?: boolean;
28
45
  }
29
46
 
30
47
  /** One roster row (GET /public/community/spaces/:id/members). */
@@ -80,7 +97,8 @@ export interface CommunityPost {
80
97
  id: string;
81
98
  profileId: string;
82
99
  spaceId: string;
83
- authorAccountId: string;
100
+ /** Null when the community AI wrote it (`agentAuthor` is then set). */
101
+ authorAccountId: string | null;
84
102
  title: string | null;
85
103
  /** Lexical editor state (JSON). Render with <CommunityRichText/>. */
86
104
  body: unknown;
@@ -92,22 +110,119 @@ export interface CommunityPost {
92
110
  lastActivityAt: string | null;
93
111
  createdAt: string;
94
112
  author: CommunityAuthor | null;
113
+ /** The community AI, when it wrote this post. `author` is then null. Absent on older servers. */
114
+ agentAuthor?: CommunityAgentAuthor | null;
95
115
  media: CommunityMediaItem[];
96
116
  reactions?: ReactionSummary[];
117
+ /**
118
+ * Moderation state. Only an author sees their own pending post; everyone else
119
+ * sees published posts only. Absent on rows from older servers: treat as published.
120
+ */
121
+ status?: CommunityContentStatus;
122
+ /** Why a post was held (`approval_new_member`, `approval_all`, `keyword:<word>`), or why it was rejected. */
123
+ moderationReason?: string | null;
124
+ /** Set in the cache when a moderator removes the post in this session, so the undo stays in place. */
125
+ archivedAt?: string | null;
126
+ /**
127
+ * The post's poll, as this viewer sees it, or null when it has none. Absent
128
+ * on a post that arrived over the realtime socket (see `CommunityPollView`).
129
+ */
130
+ poll?: CommunityPollView | null;
131
+ /**
132
+ * A card for the first link in the post, or null. Filled in the background
133
+ * shortly after the post is published, so a new post has none at first.
134
+ * Absent on rows from older servers.
135
+ */
136
+ linkPreview?: CommunityLinkPreview | null;
137
+ }
138
+
139
+ /** A link card on a post (community parity §6.2). */
140
+ export interface CommunityLinkPreview {
141
+ url: string;
142
+ kind: "link" | "video";
143
+ provider: "youtube" | "vimeo" | "loom" | null;
144
+ siteName: string | null;
145
+ title: string | null;
146
+ description: string | null;
147
+ imageUrl: string | null;
148
+ /** The player address, for a video. Render only through `safeEmbedUrl`. */
149
+ embedUrl: string | null;
150
+ }
151
+
152
+ // ── Polls (community parity §6.1) ────────────────────────────────────────────
153
+
154
+ /** When a poll's counts are shown: at once, once the member voted, or once it closed. */
155
+ export type CommunityPollResultsVisibility = "always" | "after_vote" | "after_close";
156
+
157
+ export const COMMUNITY_POLL_RESULTS_VISIBILITY: readonly CommunityPollResultsVisibility[] = [
158
+ "always",
159
+ "after_vote",
160
+ "after_close",
161
+ ];
162
+
163
+ export interface CommunityPollOption {
164
+ id: string;
165
+ label: string;
166
+ position: number;
167
+ /** Null while this viewer may not see results yet. */
168
+ votes: number | null;
169
+ }
170
+
171
+ /** A post's poll as the viewer sees it. The post title is its question. */
172
+ export interface CommunityPollView {
173
+ id: string;
174
+ multipleChoice: boolean;
175
+ closesAt: string | null;
176
+ closed: boolean;
177
+ resultsVisibility: CommunityPollResultsVisibility;
178
+ /** Whether this viewer may see the counts now. */
179
+ resultsVisible: boolean;
180
+ /** Distinct members who voted. Null while results are hidden. */
181
+ totalVoters: number | null;
182
+ /** The option ids this viewer chose. Empty when they have not voted. */
183
+ myVotes: string[];
184
+ options: CommunityPollOption[];
185
+ }
186
+
187
+ /** The poll part of the create-post body. The question travels as the post `title`. */
188
+ export interface CommunityPollInput {
189
+ options: string[];
190
+ multipleChoice?: boolean;
191
+ /** ISO date, in the future. */
192
+ closesAt?: string | null;
193
+ resultsVisibility?: CommunityPollResultsVisibility;
194
+ }
195
+
196
+ /** Where a post or reply stands in moderation. */
197
+ export type CommunityContentStatus = "published" | "pending" | "rejected";
198
+
199
+ /** True when a post or reply is waiting for a moderator (only its author ever sees it). */
200
+ export function isCommunityContentPending(
201
+ item: { status?: CommunityContentStatus | string | null } | null | undefined,
202
+ ): boolean {
203
+ return item?.status === "pending";
97
204
  }
98
205
 
99
206
  export interface CommunityReply {
100
207
  id: string;
101
208
  postId: string;
102
209
  parentReplyId: string | null;
103
- authorAccountId: string;
210
+ /** Null when the community AI wrote it (`agentAuthor` is then set). */
211
+ authorAccountId: string | null;
104
212
  body: unknown;
105
213
  bodyText: string | null;
106
214
  reactionCount: number;
107
215
  createdAt: string;
108
216
  author: CommunityAuthor | null;
217
+ /** The community AI, when this reply is its answer. `author` is then null. */
218
+ agentAuthor?: CommunityAgentAuthor | null;
109
219
  media: CommunityMediaItem[];
110
220
  reactions?: ReactionSummary[];
221
+ /** See `CommunityPost.status`. */
222
+ status?: CommunityContentStatus;
223
+ moderationReason?: string | null;
224
+ /** Set in the cache when a moderator removes the reply in this session. */
225
+ archivedAt?: string | null;
111
226
  }
112
227
 
113
228
  /** Attachment input shape the community create endpoints accept. */
@@ -136,6 +251,20 @@ export interface CommunityMemberProfile {
136
251
  createdAt: string;
137
252
  firstName: string | null;
138
253
  lastName: string | null;
254
+ /**
255
+ * Only on `/me`: whether this member moderates THIS community (the moderator
256
+ * role here, or an owner/admin of the profile). Display only; the server
257
+ * checks again on every moderation call.
258
+ */
259
+ canModerate?: boolean;
260
+ /** Listed in the member directory (default true). */
261
+ showInDirectory?: boolean;
262
+ /** Counted on the member map, by country only (default false). */
263
+ showOnMap?: boolean;
264
+ /** Free text, display only. */
265
+ city?: string | null;
266
+ /** ISO 3166-1 alpha-2, or null. */
267
+ countryCode?: string | null;
139
268
  recentPosts?: {
140
269
  id: string;
141
270
  spaceId: string;
@@ -158,17 +287,30 @@ export interface CommunityMemberProfile {
158
287
 
159
288
  export type CommunityDigestCadence = "off" | "inapp_only" | "hourly" | "daily";
160
289
 
161
- export type CommunityNotificationType = "mention" | "reply" | "reaction" | "new_post" | "mod";
290
+ export type CommunityNotificationType = "mention" | "reply" | "reaction" | "new_post" | "mod" | "level_up";
162
291
 
163
292
  export interface CommunityNotificationItem {
164
293
  id: string;
165
294
  profileId: string;
166
295
  accountId: string;
167
296
  type: CommunityNotificationType | string;
168
- entityType: "community_post" | "community_reply" | string | null;
297
+ entityType: "community_post" | "community_reply" | "community" | string | null;
169
298
  entityId: string | null;
170
299
  readAt: string | null;
171
300
  createdAt: string;
301
+ /**
302
+ * Extra detail for some types. A `mod` notice about a post or reply carries
303
+ * the moderation outcome; a `mod` notice about the community (a mute) has none.
304
+ */
305
+ data?: CommunityNotificationData | null;
306
+ }
307
+
308
+ export interface CommunityNotificationData {
309
+ outcome?: "approved" | "rejected";
310
+ reason?: string | null;
311
+ /** On a `level_up`: the level the member reached. */
312
+ level?: number;
313
+ [key: string]: unknown;
172
314
  }
173
315
 
174
316
  export interface CommunityLeaderboardEntry {
@@ -234,35 +376,40 @@ export function communityMemberName(
234
376
  }
235
377
 
236
378
  // 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.
379
+ // same caches the data hooks populate: mirrors chatKeys in useChat.ts.
238
380
  export const communityKeys = {
239
- spaces: (profileId?: string) => ["public-community-spaces", profileId] as const,
381
+ // Scoped by COMMUNITY, not profile: a profile runs several, and one cache
382
+ // entry per profile would show community A's spaces inside community B.
383
+ spaces: (communityId?: string) => ["public-community-spaces", communityId] as const,
240
384
  spaceMembers: (spaceId?: string) => ["public-community-space-members", spaceId] as const,
241
385
  posts: (spaceId?: string) => ["public-community-posts", spaceId] as const,
242
386
  post: (postId?: string) => ["public-community-post", postId] as const,
243
387
  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,
388
+ myProfile: (communityId?: string) => ["public-community-my-profile", communityId] as const,
389
+ member: (communityId?: string, accountId?: string) => ["public-community-member", communityId, accountId] as const,
390
+ notifications: (communityId?: string, unreadOnly?: boolean) =>
391
+ ["public-community-notifications", communityId, unreadOnly] as const,
392
+ unread: (communityId?: string) => ["public-community-unread", communityId] as const,
393
+ leaderboard: (communityId?: string, period?: string) => ["public-community-leaderboard", communityId, period] as const,
394
+ search: (communityId?: string, q?: string, spaceId?: string) =>
395
+ ["public-community-search", communityId, q, spaceId ?? null] as const,
396
+ moderationQueue: (communityId?: string) => ["public-community-moderation-queue", communityId] as const,
397
+ moderationLog: (communityId?: string, page?: number) =>
398
+ ["public-community-moderation-log", communityId, page] as const,
252
399
  };
253
400
 
254
401
  // ── Data hooks ───────────────────────────────────────────────────────────────
255
402
 
256
403
  /** Spaces the current member can access (GET /public/community/spaces). */
257
404
  export function useCommunitySpaces() {
258
- const { client, profileId } = useForge();
405
+ const { client, profileId, communityId } = useCommunityScope("useCommunitySpaces()");
259
406
  return useQuery<CommunitySpace[]>({
260
- queryKey: communityKeys.spaces(profileId),
407
+ queryKey: communityKeys.spaces(communityId),
261
408
  queryFn: async () => {
262
- const res = await client.get("/public/community/spaces", { params: { profileId } });
409
+ const res = await client.get("/public/community/spaces", { params: { profileId, communityId } });
263
410
  return res.data;
264
411
  },
265
- enabled: !!profileId && !!client,
412
+ enabled: !!profileId && !!communityId && !!client,
266
413
  });
267
414
  }
268
415
 
@@ -274,30 +421,30 @@ export interface SpaceMembershipResult {
274
421
  /** Patch one space's isJoined/memberCount inside the spaces list cache. */
275
422
  export function setSpaceMembership(
276
423
  queryClient: QueryClient,
277
- profileId: string | undefined,
424
+ communityId: string | undefined,
278
425
  spaceId: string,
279
426
  membership: SpaceMembershipResult,
280
427
  ) {
281
- queryClient.setQueryData(communityKeys.spaces(profileId), (old: CommunitySpace[] | undefined) =>
428
+ queryClient.setQueryData(communityKeys.spaces(communityId), (old: CommunitySpace[] | undefined) =>
282
429
  old?.map((s) => (s.id === spaceId ? { ...s, ...membership } : s)),
283
430
  );
284
431
  }
285
432
 
286
433
  /**
287
434
  * 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
435
+ * who can access a space: joining opts into new-post notifications/digests
289
436
  * and the roster. Interacting (post/reply/react) auto-joins server-side.
290
437
  */
291
438
  export function useJoinCommunitySpace() {
292
- const { client, profileId } = useForge();
439
+ const { client, profileId, communityId } = useCommunityScope("useJoinCommunitySpace()");
293
440
  const queryClient = useQueryClient();
294
441
  return useMutation({
295
442
  mutationFn: async (spaceId: string) => {
296
- const res = await client.post(`/public/community/spaces/${spaceId}/join`, { profileId });
443
+ const res = await client.post(`/public/community/spaces/${spaceId}/join`, { profileId, communityId });
297
444
  return { ...(res.data as SpaceMembershipResult), spaceId };
298
445
  },
299
446
  onSuccess: ({ spaceId, ...membership }) => {
300
- setSpaceMembership(queryClient, profileId, spaceId, membership);
447
+ setSpaceMembership(queryClient, communityId, spaceId, membership);
301
448
  queryClient.invalidateQueries({ queryKey: communityKeys.spaceMembers(spaceId) });
302
449
  },
303
450
  });
@@ -305,15 +452,15 @@ export function useJoinCommunitySpace() {
305
452
 
306
453
  /** Leave a space (POST /public/community/spaces/:id/leave). Always explicit. */
307
454
  export function useLeaveCommunitySpace() {
308
- const { client, profileId } = useForge();
455
+ const { client, profileId, communityId } = useCommunityScope("useLeaveCommunitySpace()");
309
456
  const queryClient = useQueryClient();
310
457
  return useMutation({
311
458
  mutationFn: async (spaceId: string) => {
312
- const res = await client.post(`/public/community/spaces/${spaceId}/leave`, { profileId });
459
+ const res = await client.post(`/public/community/spaces/${spaceId}/leave`, { profileId, communityId });
313
460
  return { ...(res.data as SpaceMembershipResult), spaceId };
314
461
  },
315
462
  onSuccess: ({ spaceId, ...membership }) => {
316
- setSpaceMembership(queryClient, profileId, spaceId, membership);
463
+ setSpaceMembership(queryClient, communityId, spaceId, membership);
317
464
  queryClient.invalidateQueries({ queryKey: communityKeys.spaceMembers(spaceId) });
318
465
  },
319
466
  });
@@ -321,98 +468,135 @@ export function useLeaveCommunitySpace() {
321
468
 
322
469
  /** Paginated joined-member roster for a space. */
323
470
  export function useCommunitySpaceMembers(spaceId?: string, limit = 50) {
324
- const { client, profileId } = useForge();
471
+ const { client, profileId, communityId } = useCommunityScope("useCommunitySpaceMembers()");
325
472
  return useInfiniteQuery<CommunitySpaceMembersPage>({
326
473
  queryKey: communityKeys.spaceMembers(spaceId),
327
474
  queryFn: async ({ pageParam = 1 }) => {
328
475
  const res = await client.get(`/public/community/spaces/${spaceId}/members`, {
329
- params: { profileId, page: pageParam, limit },
476
+ params: { profileId, communityId, page: pageParam, limit },
330
477
  });
331
478
  return res.data;
332
479
  },
333
480
  initialPageParam: 1,
334
481
  getNextPageParam: (lastPage) => (lastPage.hasNextPage ? lastPage.page + 1 : undefined),
335
- enabled: !!spaceId && !!client && !!profileId,
482
+ enabled: !!spaceId && !!client && !!profileId && !!communityId,
336
483
  });
337
484
  }
338
485
 
339
486
  /** Paginated posts for a space, pinned first then most recent activity. */
340
487
  export function useCommunitySpacePosts(spaceId?: string, limit = 20) {
341
- const { client, profileId } = useForge();
488
+ const { client, profileId, communityId } = useCommunityScope("useCommunitySpacePosts()");
342
489
  return useInfiniteQuery<CommunityPostsPage>({
343
490
  queryKey: communityKeys.posts(spaceId),
344
491
  queryFn: async ({ pageParam = 1 }) => {
345
492
  const res = await client.get(`/public/community/spaces/${spaceId}/posts`, {
346
- params: { profileId, page: pageParam, limit },
493
+ params: { profileId, communityId, page: pageParam, limit },
347
494
  });
348
495
  return res.data;
349
496
  },
350
497
  initialPageParam: 1,
351
498
  getNextPageParam: (lastPage) => (lastPage.hasNextPage ? lastPage.page + 1 : undefined),
352
- enabled: !!spaceId && !!client && !!profileId,
499
+ enabled: !!spaceId && !!client && !!profileId && !!communityId,
353
500
  });
354
501
  }
355
502
 
356
503
  /**
357
504
  * 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
505
+ * replies page: we request the minimum (limit=1) and let the dedicated
359
506
  * replies hook own pagination, so both caches stay simple.
360
507
  */
361
508
  export function useCommunityPost(postId?: string) {
362
- const { client, profileId } = useForge();
509
+ const { client, profileId, communityId } = useCommunityScope("useCommunityPost()");
363
510
  return useQuery<CommunityPost>({
364
511
  queryKey: communityKeys.post(postId),
365
512
  queryFn: async () => {
366
513
  const res = await client.get(`/public/community/posts/${postId}`, {
367
- params: { profileId, page: 1, limit: 1 },
514
+ params: { profileId, communityId, page: 1, limit: 1 },
368
515
  });
369
516
  return res.data.post;
370
517
  },
371
- enabled: !!postId && !!client && !!profileId,
518
+ enabled: !!postId && !!client && !!profileId && !!communityId,
372
519
  });
373
520
  }
374
521
 
375
522
  /**
376
523
  * Flat, chronological reply pages for a post (GET /public/community/posts/:id/replies).
377
- * `parentReplyId` is included on every row — assemble the tree with
524
+ * `parentReplyId` is included on every row: assemble the tree with
378
525
  * `buildReplyTree` (data nesting is unlimited; the 3-level display cap is a UI concern).
379
526
  */
380
527
  export function useCommunityReplies(postId?: string, limit = 100) {
381
- const { client, profileId } = useForge();
528
+ const { client, profileId, communityId } = useCommunityScope("useCommunityReplies()");
382
529
  return useInfiniteQuery<CommunityRepliesPage>({
383
530
  queryKey: communityKeys.replies(postId),
384
531
  queryFn: async ({ pageParam = 1 }) => {
385
532
  const res = await client.get(`/public/community/posts/${postId}/replies`, {
386
- params: { profileId, page: pageParam, limit },
533
+ params: { profileId, communityId, page: pageParam, limit },
387
534
  });
388
535
  return res.data;
389
536
  },
390
537
  initialPageParam: 1,
391
538
  getNextPageParam: (lastPage) => (lastPage.hasNextPage ? lastPage.page + 1 : undefined),
392
- enabled: !!postId && !!client && !!profileId,
539
+ enabled: !!postId && !!client && !!profileId && !!communityId,
393
540
  });
394
541
  }
395
542
 
396
543
  export interface CreateCommunityPostInput {
397
544
  title?: string;
398
- /** Lexical editor state — build one from plain text with `buildLexicalState`. */
545
+ /** Lexical editor state: build one from plain text with `buildLexicalState`. */
399
546
  body: unknown;
400
547
  attachments?: CommunityAttachmentInput[];
401
548
  mentionedAccountIds?: string[];
549
+ /** Attach a poll. `title` is then its question, and required. */
550
+ poll?: CommunityPollInput;
402
551
  }
403
552
 
404
553
  /** Create a post in a space (POST /public/community/spaces/:id/posts). */
405
554
  export function useCreateCommunityPost(spaceId?: string) {
406
- const { client, profileId } = useForge();
555
+ const { client, profileId, communityId } = useCommunityScope("useCreateCommunityPost()");
407
556
  const queryClient = useQueryClient();
408
557
  return useMutation({
409
558
  mutationFn: async (input: CreateCommunityPostInput) => {
410
- const res = await client.post(`/public/community/spaces/${spaceId}/posts`, { profileId, ...input });
559
+ const res = await client.post(`/public/community/spaces/${spaceId}/posts`, { profileId, communityId, ...input });
411
560
  return res.data as CommunityPost;
412
561
  },
413
562
  onSuccess: (post) => {
414
563
  prependPostToFeed(queryClient, spaceId, post);
415
- queryClient.invalidateQueries({ queryKey: communityKeys.spaces(profileId) });
564
+ queryClient.invalidateQueries({ queryKey: communityKeys.spaces(communityId) });
565
+ },
566
+ });
567
+ }
568
+
569
+ export interface VoteCommunityPollInput {
570
+ postId: string;
571
+ /**
572
+ * The member's WHOLE answer: it replaces any earlier vote. An empty list
573
+ * withdraws the vote.
574
+ */
575
+ optionIds: string[];
576
+ /** Cache hint: the feed the post sits in. Omitted, every loaded feed is patched. */
577
+ spaceId?: string;
578
+ }
579
+
580
+ /**
581
+ * Vote on a post's poll (POST /public/community/posts/:id/poll/vote). Answers
582
+ * the updated poll, which lands in the feed and post caches. The server answers
583
+ * 409 once the poll has closed, 400 for an option that is not on it (or several
584
+ * on a single-choice poll), and 403 to a muted member or one without access.
585
+ */
586
+ export function useVoteCommunityPoll() {
587
+ const { client, profileId, communityId } = useCommunityScope("useVoteCommunityPoll()");
588
+ const queryClient = useQueryClient();
589
+ return useMutation({
590
+ mutationFn: async (input: VoteCommunityPollInput) => {
591
+ const res = await client.post(`/public/community/posts/${input.postId}/poll/vote`, {
592
+ profileId,
593
+ communityId,
594
+ optionIds: input.optionIds,
595
+ });
596
+ return { poll: res.data as CommunityPollView, input };
597
+ },
598
+ onSuccess: ({ poll, input }) => {
599
+ setPostPoll(queryClient, { postId: input.postId, spaceId: input.spaceId }, poll);
416
600
  },
417
601
  });
418
602
  }
@@ -424,19 +608,19 @@ export interface CreateCommunityReplyInput {
424
608
  mentionedAccountIds?: string[];
425
609
  }
426
610
 
427
- /** Reply to a post — optionally nested under another reply. */
611
+ /** Reply to a post: optionally nested under another reply. */
428
612
  export function useCreateCommunityReply(postId?: string) {
429
- const { client, profileId } = useForge();
613
+ const { client, profileId, communityId } = useCommunityScope("useCreateCommunityReply()");
430
614
  const queryClient = useQueryClient();
431
615
  return useMutation({
432
616
  mutationFn: async (input: CreateCommunityReplyInput) => {
433
- const res = await client.post(`/public/community/posts/${postId}/replies`, { profileId, ...input });
617
+ const res = await client.post(`/public/community/posts/${postId}/replies`, { profileId, communityId, ...input });
434
618
  return res.data as CommunityReply;
435
619
  },
436
620
  onSuccess: (reply) => {
437
621
  appendReplyToThread(queryClient, postId, reply);
438
622
  // Replying implicitly joins the post's space server-side.
439
- queryClient.invalidateQueries({ queryKey: communityKeys.spaces(profileId) });
623
+ queryClient.invalidateQueries({ queryKey: communityKeys.spaces(communityId) });
440
624
  },
441
625
  });
442
626
  }
@@ -452,7 +636,7 @@ export interface ToggleReactionResult {
452
636
 
453
637
  /** Toggle an emoji reaction (POST /public/community/:entityType/:id/react). */
454
638
  export function useToggleCommunityReaction() {
455
- const { client, profileId } = useForge();
639
+ const { client, profileId, communityId } = useCommunityScope("useToggleCommunityReaction()");
456
640
  const queryClient = useQueryClient();
457
641
  return useMutation({
458
642
  mutationFn: async (input: {
@@ -465,6 +649,7 @@ export function useToggleCommunityReaction() {
465
649
  }) => {
466
650
  const res = await client.post(`/public/community/${input.entityType}/${input.entityId}/react`, {
467
651
  profileId,
652
+ communityId,
468
653
  emoji: input.emoji,
469
654
  });
470
655
  return { ...(res.data as ToggleReactionResult), input };
@@ -472,14 +657,14 @@ export function useToggleCommunityReaction() {
472
657
  onSuccess: ({ reactions, input }) => {
473
658
  setReactionSummary(queryClient, input, reactions);
474
659
  // Adding a reaction implicitly joins the space server-side.
475
- queryClient.invalidateQueries({ queryKey: communityKeys.spaces(profileId) });
660
+ queryClient.invalidateQueries({ queryKey: communityKeys.spaces(communityId) });
476
661
  },
477
662
  });
478
663
  }
479
664
 
480
665
  /** Report a post or reply to the moderators. */
481
666
  export function useReportCommunityContent() {
482
- const { client, profileId } = useForge();
667
+ const { client, profileId, communityId } = useCommunityScope("useReportCommunityContent()");
483
668
  return useMutation({
484
669
  mutationFn: async (input: {
485
670
  entityId: string;
@@ -488,6 +673,7 @@ export function useReportCommunityContent() {
488
673
  }) => {
489
674
  const res = await client.post(`/public/community/posts/${input.entityId}/report`, {
490
675
  profileId,
676
+ communityId,
491
677
  entityType: input.entityType,
492
678
  reason: input.reason,
493
679
  });
@@ -496,22 +682,33 @@ export function useReportCommunityContent() {
496
682
  });
497
683
  }
498
684
 
499
- /** The member's own community profile — created on first read (GET /public/community/me). */
685
+ /** The member's own community profile: created on first read (GET /public/community/me). */
500
686
  export function useMyCommunityProfile(enabled = true) {
501
- const { client, profileId } = useForge();
687
+ const { client, profileId, communityId } = useCommunityScope("useMyCommunityProfile()");
502
688
  return useQuery<CommunityMemberProfile>({
503
- queryKey: communityKeys.myProfile(profileId),
689
+ queryKey: communityKeys.myProfile(communityId),
504
690
  queryFn: async () => {
505
- const res = await client.get("/public/community/me", { params: { profileId } });
691
+ const res = await client.get("/public/community/me", { params: { profileId, communityId } });
506
692
  return res.data;
507
693
  },
508
- enabled: enabled && !!profileId && !!client,
694
+ enabled: enabled && !!profileId && !!communityId && !!client,
509
695
  });
510
696
  }
511
697
 
698
+ /**
699
+ * Whether the signed-in member moderates THIS community (`canModerate` on
700
+ * `/me`), for showing the moderation tools. Display only: every moderation
701
+ * call is checked again by the server. Reads the same cache entry as
702
+ * `useMyCommunityProfile`, and stays false (with no request) until `enabled`.
703
+ */
704
+ export function useCommunityCanModerate(enabled = true): boolean {
705
+ const { data } = useMyCommunityProfile(enabled);
706
+ return enabled && !!data?.canModerate;
707
+ }
708
+
512
709
  /** Update display name / bio / avatar / digest cadence (PUT /public/community/me). */
513
710
  export function useUpdateMyCommunityProfile() {
514
- const { client, profileId } = useForge();
711
+ const { client, profileId, communityId } = useCommunityScope("useUpdateMyCommunityProfile()");
515
712
  const queryClient = useQueryClient();
516
713
  return useMutation({
517
714
  mutationFn: async (input: {
@@ -519,128 +716,174 @@ export function useUpdateMyCommunityProfile() {
519
716
  bio?: string | null;
520
717
  avatarUrl?: string | null;
521
718
  digestCadence?: CommunityDigestCadence;
719
+ showInDirectory?: boolean;
720
+ showOnMap?: boolean;
721
+ city?: string | null;
722
+ countryCode?: string | null;
522
723
  }) => {
523
- const res = await client.put("/public/community/me", { profileId, ...input });
724
+ const res = await client.put("/public/community/me", { profileId, communityId, ...input });
524
725
  return res.data as CommunityMemberProfile;
525
726
  },
526
727
  onSuccess: (profile) => {
527
- queryClient.setQueryData(communityKeys.myProfile(profileId), profile);
528
- queryClient.invalidateQueries({ queryKey: communityKeys.member(profileId, profile?.accountId) });
728
+ // Merged, not replaced: `/me` carries fields the PUT answer may not
729
+ // (`canModerate`), and dropping them would hide the moderator's tools.
730
+ queryClient.setQueryData(communityKeys.myProfile(communityId), (old: CommunityMemberProfile | undefined) =>
731
+ old ? { ...old, ...profile } : profile,
732
+ );
733
+ queryClient.invalidateQueries({ queryKey: communityKeys.member(communityId, profile?.accountId) });
734
+ // Directory and map visibility may have changed.
735
+ queryClient.invalidateQueries({ queryKey: ["public-community-directory", communityId] });
736
+ queryClient.invalidateQueries({ queryKey: ["public-community-member-map", communityId] });
529
737
  },
530
738
  });
531
739
  }
532
740
 
533
741
  /** Another member's profile + recent activity (filtered to spaces you can read). */
534
742
  export function useCommunityMemberProfile(accountId?: string) {
535
- const { client, profileId } = useForge();
743
+ const { client, profileId, communityId } = useCommunityScope("useCommunityMemberProfile()");
536
744
  return useQuery<CommunityMemberProfile>({
537
- queryKey: communityKeys.member(profileId, accountId),
745
+ queryKey: communityKeys.member(communityId, accountId),
538
746
  queryFn: async () => {
539
- const res = await client.get(`/public/community/profile/${accountId}`, { params: { profileId } });
747
+ const res = await client.get(`/public/community/profile/${accountId}`, { params: { profileId, communityId } });
540
748
  return res.data;
541
749
  },
542
- enabled: !!accountId && !!profileId && !!client,
750
+ enabled: !!accountId && !!profileId && !!communityId && !!client,
543
751
  });
544
752
  }
545
753
 
546
754
  /** In-app inbox (GET /public/community/notifications). Returns { data, unreadCount }. */
547
755
  export function useCommunityNotifications({ unreadOnly = false, limit = 50 } = {}) {
548
- const { client, profileId } = useForge();
756
+ const { client, profileId, communityId } = useCommunityScope("useCommunityNotifications()");
549
757
  return useQuery<{ data: CommunityNotificationItem[]; unreadCount: number }>({
550
- queryKey: communityKeys.notifications(profileId, unreadOnly),
758
+ queryKey: communityKeys.notifications(communityId, unreadOnly),
551
759
  queryFn: async () => {
552
760
  const res = await client.get("/public/community/notifications", {
553
- params: { profileId, page: 1, limit, unreadOnly },
761
+ params: { profileId, communityId, page: 1, limit, unreadOnly },
554
762
  });
555
763
  return res.data;
556
764
  },
557
- enabled: !!profileId && !!client,
765
+ enabled: !!profileId && !!communityId && !!client,
558
766
  });
559
767
  }
560
768
 
561
769
  /** Unread count for the bell, polled every 30s (same cadence as chat unread counts). */
562
770
  export function useCommunityUnreadCount(enabled = true) {
563
- const { client, profileId } = useForge();
771
+ const { client, profileId, communityId } = useCommunityScope("useCommunityUnreadCount()");
564
772
  return useQuery<number>({
565
- queryKey: communityKeys.unread(profileId),
773
+ queryKey: communityKeys.unread(communityId),
566
774
  queryFn: async () => {
567
775
  const res = await client.get("/public/community/notifications", {
568
- params: { profileId, page: 1, limit: 1, unreadOnly: true },
776
+ params: { profileId, communityId, page: 1, limit: 1, unreadOnly: true },
569
777
  });
570
778
  return res.data.unreadCount as number;
571
779
  },
572
- enabled: enabled && !!profileId && !!client,
780
+ enabled: enabled && !!profileId && !!communityId && !!client,
573
781
  refetchInterval: 30000,
574
782
  });
575
783
  }
576
784
 
577
- /** Mark notifications read — omit ids to mark ALL unread as read. */
785
+ /** Mark notifications read: omit ids to mark ALL unread as read. */
578
786
  export function useMarkCommunityNotificationsRead() {
579
- const { client, profileId } = useForge();
787
+ const { client, profileId, communityId } = useCommunityScope("useMarkCommunityNotificationsRead()");
580
788
  const queryClient = useQueryClient();
581
789
  return useMutation({
582
790
  mutationFn: async (ids?: string[]) => {
583
- await client.post("/public/community/notifications/mark-read", { profileId, ids });
791
+ await client.post("/public/community/notifications/mark-read", { profileId, communityId, ids });
584
792
  },
585
793
  onSuccess: () => {
586
794
  queryClient.invalidateQueries({ queryKey: ["public-community-notifications"] });
587
- queryClient.invalidateQueries({ queryKey: communityKeys.unread(profileId) });
795
+ queryClient.invalidateQueries({ queryKey: communityKeys.unread(communityId) });
588
796
  },
589
797
  });
590
798
  }
591
799
 
592
800
  /** Points leaderboard (GET /public/community/leaderboard?period=all|7|30). */
593
801
  export function useCommunityLeaderboard(period: CommunityLeaderboardPeriod = "all", limit = 20) {
594
- const { client, profileId } = useForge();
802
+ const { client, profileId, communityId } = useCommunityScope("useCommunityLeaderboard()");
595
803
  return useQuery<CommunityLeaderboardEntry[]>({
596
- queryKey: communityKeys.leaderboard(profileId, period),
804
+ queryKey: communityKeys.leaderboard(communityId, period),
597
805
  queryFn: async () => {
598
- const res = await client.get("/public/community/leaderboard", { params: { profileId, period, limit } });
806
+ const res = await client.get("/public/community/leaderboard", { params: { profileId, communityId, period, limit } });
599
807
  return res.data;
600
808
  },
601
- enabled: !!profileId && !!client,
809
+ enabled: !!profileId && !!communityId && !!client,
602
810
  });
603
811
  }
604
812
 
813
+ /** How long the typed @query settles before the member search runs. */
814
+ export const MENTION_SEARCH_DEBOUNCE_MS = 150;
815
+
605
816
  /**
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.
817
+ * The member search behind @mention: the typed text as `q` (left out when
818
+ * blank, so a bare "@" lists the most active members), ten results, most
819
+ * recently active first.
610
820
  */
611
- export function useCommunityMentionDirectory() {
612
- const { client, profileId } = useForge();
821
+ export function mentionDirectoryParams(q: string | null | undefined): Record<string, string | number> {
822
+ const trimmed = (q ?? "").trim().slice(0, 80);
823
+ return { ...(trimmed ? { q: trimmed } : {}), limit: 10, sort: "active" };
824
+ }
825
+
826
+ /**
827
+ * Members for @mention autocomplete, searched on the server as the member
828
+ * types (GET /public/community/members), so every current member can be
829
+ * mentioned, not just the hundred with the most points. Pass the text after
830
+ * the "@"; pass `undefined` while no @token is active and nothing is asked
831
+ * for. Rows come back in the leaderboard entry shape the composers read.
832
+ */
833
+ export function useCommunityMentionDirectory(query?: string | null) {
834
+ const { client, profileId, communityId } = useCommunityScope("useCommunityMentionDirectory()");
835
+ const active = query !== undefined && query !== null;
836
+ // null while no @token is open. The first text after "@" is searched at
837
+ // once; typing after that settles first, so a request goes per pause.
838
+ const [settled, setSettled] = useState<string | null>(null);
839
+ useEffect(() => {
840
+ if (!active) {
841
+ setSettled(null);
842
+ return;
843
+ }
844
+ const id = setTimeout(() => setSettled(query ?? ""), MENTION_SEARCH_DEBOUNCE_MS);
845
+ return () => clearTimeout(id);
846
+ }, [query, active]);
847
+ const params = mentionDirectoryParams(settled ?? query);
613
848
  return useQuery<CommunityLeaderboardEntry[]>({
614
- queryKey: communityKeys.leaderboard(profileId, "mention-directory"),
849
+ queryKey: ["public-community-mention-search", communityId, params] as const,
615
850
  queryFn: async () => {
616
- const res = await client.get("/public/community/leaderboard", {
617
- params: { profileId, period: "all", limit: 100 },
618
- });
619
- return res.data;
851
+ const res = await client.get("/public/community/members", { params: { profileId, communityId, ...params } });
852
+ const rows = (res.data?.data ?? []) as Array<CommunityLeaderboardEntry & { points?: number }>;
853
+ return rows.map((m) => ({
854
+ accountId: m.accountId,
855
+ displayName: m.displayName,
856
+ avatarUrl: m.avatarUrl,
857
+ points: m.points ?? 0,
858
+ level: m.level,
859
+ firstName: m.firstName,
860
+ lastName: m.lastName,
861
+ }));
620
862
  },
621
- enabled: !!profileId && !!client,
622
- staleTime: 60_000,
863
+ placeholderData: keepPreviousData,
864
+ enabled: active && !!profileId && !!communityId && !!client,
865
+ staleTime: 30_000,
623
866
  });
624
867
  }
625
868
 
626
869
  /**
627
870
  * 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 —
871
+ * pass `spaceId` (a space uuid OR its per-profile slug) to scope to one space:
629
872
  * scoping can only narrow, never widen, access. Backward-compatible: called with
630
873
  * just a query it searches every accessible space (prior behavior).
631
874
  */
632
875
  export function useCommunitySearch(q: string, spaceId?: string) {
633
- const { client, profileId } = useForge();
876
+ const { client, profileId, communityId } = useCommunityScope("useCommunitySearch()");
634
877
  const trimmed = q.trim();
635
878
  return useQuery<CommunitySearchResults>({
636
- queryKey: communityKeys.search(profileId, trimmed, spaceId),
879
+ queryKey: communityKeys.search(communityId, trimmed, spaceId),
637
880
  queryFn: async () => {
638
881
  const res = await client.get("/public/community/search", {
639
- params: { profileId, q: trimmed, page: 1, limit: 20, ...(spaceId ? { spaceId } : {}) },
882
+ params: { profileId, communityId, q: trimmed, page: 1, limit: 20, ...(spaceId ? { spaceId } : {}) },
640
883
  });
641
884
  return res.data;
642
885
  },
643
- enabled: trimmed.length >= 2 && !!profileId && !!client,
886
+ enabled: trimmed.length >= 2 && !!profileId && !!communityId && !!client,
644
887
  });
645
888
  }
646
889
 
@@ -714,6 +957,32 @@ export function bumpFeedReplyCount(queryClient: QueryClient, spaceId: string | u
714
957
  });
715
958
  }
716
959
 
960
+ /**
961
+ * Put a post's updated poll into every cache that renders the post: its feed
962
+ * (the one named, or every loaded feed when none is) and the single-post read.
963
+ */
964
+ export function setPostPoll(
965
+ queryClient: QueryClient,
966
+ target: { postId: string; spaceId?: string },
967
+ poll: CommunityPollView | null,
968
+ ) {
969
+ const patchFeed = (old: any) => {
970
+ if (!old?.pages?.length) return old;
971
+ return {
972
+ ...old,
973
+ pages: old.pages.map((page: any) => ({
974
+ ...page,
975
+ data: page.data.map((p: CommunityPost) => (p.id === target.postId ? { ...p, poll } : p)),
976
+ })),
977
+ };
978
+ };
979
+ if (target.spaceId) queryClient.setQueryData(communityKeys.posts(target.spaceId), patchFeed);
980
+ else queryClient.setQueriesData({ queryKey: ["public-community-posts"] }, patchFeed);
981
+ queryClient.setQueryData(communityKeys.post(target.postId), (old: CommunityPost | undefined) =>
982
+ old ? { ...old, poll } : old,
983
+ );
984
+ }
985
+
717
986
  /** Replace an entity's reaction summary in every cache that renders it. */
718
987
  export function setReactionSummary(
719
988
  queryClient: QueryClient,
@@ -792,3 +1061,82 @@ export function applyReactionDelta(
792
1061
  });
793
1062
  }
794
1063
  }
1064
+
1065
+ /** The `communityContentHeld` socket payload: auto-moderation sent it back to the approval queue. */
1066
+ export interface CommunityContentHeldEvent {
1067
+ spaceId: string;
1068
+ entityType: "community_post" | "community_reply";
1069
+ postId: string | null;
1070
+ replyId: string | null;
1071
+ }
1072
+
1073
+ /**
1074
+ * Take a post or reply that was just held for approval out of the open feeds
1075
+ * and threads. Everyone else loses it at once; its author keeps it, marked as
1076
+ * waiting for approval, exactly as the server shows them their own held work.
1077
+ * An open post that is held for a reader who is not its author becomes `null`,
1078
+ * which the thread renders as unavailable.
1079
+ */
1080
+ export function applyCommunityContentHeld(
1081
+ queryClient: QueryClient,
1082
+ event: CommunityContentHeldEvent,
1083
+ viewerAccountId: string | null | undefined,
1084
+ ) {
1085
+ const mine = (authorAccountId: string | null | undefined) => !!viewerAccountId && authorAccountId === viewerAccountId;
1086
+ const asPending = <T extends { status?: CommunityContentStatus }>(item: T): T => ({ ...item, status: "pending" });
1087
+
1088
+ if (event.entityType === "community_post" && event.postId) {
1089
+ const postId = event.postId;
1090
+ queryClient.setQueryData(communityKeys.posts(event.spaceId), (old: any) => {
1091
+ if (!old?.pages?.length) return old;
1092
+ return {
1093
+ ...old,
1094
+ pages: old.pages.map((page: any) => {
1095
+ const had = page.data.some((p: CommunityPost) => p.id === postId);
1096
+ if (!had) return page;
1097
+ const data = page.data.flatMap((p: CommunityPost) =>
1098
+ p.id !== postId ? [p] : mine(p.authorAccountId) ? [asPending(p)] : [],
1099
+ );
1100
+ return { ...page, data, total: Math.max(0, page.total - (page.data.length - data.length)) };
1101
+ }),
1102
+ };
1103
+ });
1104
+ queryClient.setQueryData(communityKeys.post(postId), (old: CommunityPost | null | undefined) =>
1105
+ old ? (mine(old.authorAccountId) ? asPending(old) : null) : old,
1106
+ );
1107
+ return;
1108
+ }
1109
+
1110
+ if (event.entityType === "community_reply" && event.postId && event.replyId) {
1111
+ const { postId, replyId } = event;
1112
+ let removed = false;
1113
+ queryClient.setQueryData(communityKeys.replies(postId), (old: any) => {
1114
+ if (!old?.pages?.length) return old;
1115
+ return {
1116
+ ...old,
1117
+ pages: old.pages.map((page: any) => {
1118
+ if (!page.data.some((r: CommunityReply) => r.id === replyId)) return page;
1119
+ const data = page.data.flatMap((r: CommunityReply) =>
1120
+ r.id !== replyId ? [r] : mine(r.authorAccountId) ? [asPending(r)] : [],
1121
+ );
1122
+ if (data.length < page.data.length) removed = true;
1123
+ return { ...page, data, total: Math.max(0, page.total - (page.data.length - data.length)) };
1124
+ }),
1125
+ };
1126
+ });
1127
+ if (removed) {
1128
+ const drop = (p: CommunityPost) => ({ ...p, replyCount: Math.max(0, (p.replyCount ?? 0) - 1) });
1129
+ queryClient.setQueryData(communityKeys.post(postId), (old: CommunityPost | null | undefined) => (old ? drop(old) : old));
1130
+ queryClient.setQueryData(communityKeys.posts(event.spaceId), (old: any) => {
1131
+ if (!old?.pages?.length) return old;
1132
+ return {
1133
+ ...old,
1134
+ pages: old.pages.map((page: any) => ({
1135
+ ...page,
1136
+ data: page.data.map((p: CommunityPost) => (p.id === postId ? drop(p) : p)),
1137
+ })),
1138
+ };
1139
+ });
1140
+ }
1141
+ }
1142
+ }