@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
@@ -0,0 +1,300 @@
1
+ import { useContext, useMemo, useState } from "react";
2
+ import { PublicAuthContext } from "../../../contexts/PublicAuthContext";
3
+ import {
4
+ useCommunityCanModerate,
5
+ useMyCommunityProfile,
6
+ type CommunityNotificationItem,
7
+ type CommunityPost,
8
+ type CommunityReply,
9
+ } from "../../../data/queries/useCommunity";
10
+ import {
11
+ useCommunityModerationLog,
12
+ useCommunityModerationQueue,
13
+ useModerateCommunityPost,
14
+ useModerateCommunityReply,
15
+ useMuteCommunityMember,
16
+ useResolveCommunityReport,
17
+ type CommunityModerationLogEntry,
18
+ type CommunityPendingPost,
19
+ type CommunityPendingReply,
20
+ type CommunityPostModerationAction,
21
+ } from "../../../data/queries/useCommunityModeration";
22
+
23
+ // ── Pure helpers (unit-tested; no React) ─────────────────────────────────────
24
+
25
+ /** Why something was held, in a shape a renderer can put into words. */
26
+ export type CommunityHoldReason =
27
+ | { kind: "new_member" }
28
+ | { kind: "all_posts" }
29
+ | { kind: "keyword"; word: string }
30
+ | { kind: "other"; raw: string }
31
+ | null;
32
+
33
+ /**
34
+ * Read a held post's `moderationReason`: `approval_new_member`,
35
+ * `approval_all` or `keyword:<word>`. Anything else is passed through as
36
+ * `other`, so a reason a newer server adds still shows rather than vanishing.
37
+ */
38
+ export function describeHoldReason(reason: string | null | undefined): CommunityHoldReason {
39
+ if (!reason) return null;
40
+ if (reason === "approval_new_member") return { kind: "new_member" };
41
+ if (reason === "approval_all") return { kind: "all_posts" };
42
+ if (reason.startsWith("keyword:")) return { kind: "keyword", word: reason.slice("keyword:".length) };
43
+ return { kind: "other", raw: reason };
44
+ }
45
+
46
+ /** The mute lengths a moderator can pick on the site, in days. */
47
+ export const COMMUNITY_MUTE_DAYS = [1, 7, 30] as const;
48
+ export type CommunityMuteDays = (typeof COMMUNITY_MUTE_DAYS)[number];
49
+
50
+ /** The ISO time a mute of `days` started at `now` ends. */
51
+ export function communityMuteUntil(days: number, now: Date = new Date()): string {
52
+ return new Date(now.getTime() + days * 24 * 60 * 60 * 1000).toISOString();
53
+ }
54
+
55
+ /** The server's message on a failed call, when it sent one. */
56
+ export function moderationErrorMessage(err: unknown): string | null {
57
+ const message = (err as { response?: { data?: { message?: unknown } } })?.response?.data?.message;
58
+ return typeof message === "string" && message.trim() ? message : null;
59
+ }
60
+
61
+ /** A moderation outcome notice about the member's own post or reply. */
62
+ export interface CommunityModerationOutcome {
63
+ outcome: "approved" | "rejected";
64
+ subject: "post" | "reply";
65
+ reason: string | null;
66
+ }
67
+
68
+ /**
69
+ * The moderation outcome a `mod` notification carries, or null for any other
70
+ * notification. A `mod` notice about the community itself (a mute) carries no
71
+ * outcome and stays null, so it keeps rendering as it always has.
72
+ */
73
+ export function describeModerationNotification(
74
+ n: { type: string; entityType: string | null; data?: unknown } | null | undefined,
75
+ ): CommunityModerationOutcome | null {
76
+ if (!n || n.type !== "mod") return null;
77
+ const subject = n.entityType === "community_post" ? "post" : n.entityType === "community_reply" ? "reply" : null;
78
+ const data = (n.data && typeof n.data === "object" ? n.data : {}) as NonNullable<CommunityNotificationItem["data"]>;
79
+ const outcome = data.outcome;
80
+ if (!subject || (outcome !== "approved" && outcome !== "rejected")) return null;
81
+ const reason = typeof data.reason === "string" && data.reason.trim() ? data.reason.trim() : null;
82
+ return { outcome, subject, reason };
83
+ }
84
+
85
+ /** What a log entry can be undone to, when it can. */
86
+ export interface CommunityLogRestoreTarget {
87
+ type: "post" | "reply";
88
+ id: string;
89
+ }
90
+
91
+ /**
92
+ * Which log entries still offer "Restore": a removal (or a report resolved by
93
+ * hiding the content) of a post or reply that no NEWER entry on the page has
94
+ * already restored. The log is newest first. An entry whose restore is on an
95
+ * older page may still show the button; restoring twice is harmless.
96
+ */
97
+ export function restorableLogEntries(entries: CommunityModerationLogEntry[]): Map<string, CommunityLogRestoreTarget> {
98
+ const out = new Map<string, CommunityLogRestoreTarget>();
99
+ const restoredAfter = new Set<string>();
100
+ for (const entry of entries) {
101
+ if (!entry.entityId) continue;
102
+ const type =
103
+ entry.entityType === "community_post" ? "post" : entry.entityType === "community_reply" ? "reply" : null;
104
+ if (!type) continue;
105
+ const key = `${type}:${entry.entityId}`;
106
+ if (entry.action === "restore") {
107
+ restoredAfter.add(key);
108
+ continue;
109
+ }
110
+ if ((entry.action === "remove" || entry.action === "report_resolved_hidden") && !restoredAfter.has(key)) {
111
+ out.set(entry.id, { type, id: entry.entityId });
112
+ // An older removal of the same thing is already covered by this one.
113
+ restoredAfter.add(key);
114
+ }
115
+ }
116
+ return out;
117
+ }
118
+
119
+ // ── Hooks ────────────────────────────────────────────────────────────────────
120
+
121
+ /**
122
+ * Whether the viewer is signed in. Read without `usePublicAuth()` so the
123
+ * moderation tools render (as nothing) outside an auth provider rather than
124
+ * throwing: a post card is useful in places no one is signed in.
125
+ */
126
+ function useViewerId(): string | null {
127
+ return useContext(PublicAuthContext)?.user?.id ?? null;
128
+ }
129
+
130
+ function useIsSignedIn() {
131
+ return !!useViewerId();
132
+ }
133
+
134
+ type Outcome = Promise<boolean>;
135
+
136
+ /**
137
+ * Headless moderator actions for the inline menu on a post or reply: pin,
138
+ * lock, remove (with an optional reason) and restore, plus muting the author
139
+ * for a while. `canModerate` is false for everyone but a moderator of THIS
140
+ * community (or an owner/admin), and every action resolves `false` with
141
+ * `error` set when the server refuses. Bans are not here: only an admin bans.
142
+ */
143
+ export function useCommunityModeratorActions() {
144
+ const viewerId = useViewerId();
145
+ const canModerate = useCommunityCanModerate(!!viewerId);
146
+ const postMutation = useModerateCommunityPost();
147
+ const replyMutation = useModerateCommunityReply();
148
+ const muteMutation = useMuteCommunityMember();
149
+ const [error, setError] = useState<string | null>(null);
150
+ const [failed, setFailed] = useState(false);
151
+
152
+ const run = async (call: () => Promise<unknown>): Outcome => {
153
+ setError(null);
154
+ setFailed(false);
155
+ try {
156
+ await call();
157
+ return true;
158
+ } catch (err) {
159
+ setError(moderationErrorMessage(err));
160
+ setFailed(true);
161
+ return false;
162
+ }
163
+ };
164
+
165
+ const post = (action: CommunityPostModerationAction) => (target: Pick<CommunityPost, "id">, reason?: string) =>
166
+ run(() => postMutation.mutateAsync({ postId: target.id, action, reason }));
167
+
168
+ return {
169
+ canModerate,
170
+ /** The signed-in member, so a renderer can leave "mute" off their own content. */
171
+ currentAccountId: viewerId,
172
+ isPending: postMutation.isPending || replyMutation.isPending || muteMutation.isPending,
173
+ /** The server's message for the last failed action, when it sent one. */
174
+ error,
175
+ /** The last action failed (with or without a message from the server). */
176
+ failed,
177
+ clearError: () => {
178
+ setError(null);
179
+ setFailed(false);
180
+ },
181
+ pinPost: post("pin"),
182
+ unpinPost: post("unpin"),
183
+ lockPost: post("lock"),
184
+ unlockPost: post("unlock"),
185
+ removePost: post("remove"),
186
+ restorePost: post("restore"),
187
+ removeReply: (reply: Pick<CommunityReply, "id" | "postId">, reason?: string) =>
188
+ run(() => replyMutation.mutateAsync({ replyId: reply.id, postId: reply.postId, action: "remove", reason })),
189
+ restoreReply: (reply: Pick<CommunityReply, "id" | "postId">) =>
190
+ run(() => replyMutation.mutateAsync({ replyId: reply.id, postId: reply.postId, action: "restore" })),
191
+ /** Mute a member in this community for `days` from now. */
192
+ muteMember: (accountId: string, days: CommunityMuteDays) =>
193
+ run(() => muteMutation.mutateAsync({ accountId, mutedUntil: communityMuteUntil(days) })),
194
+ unmuteMember: (accountId: string) => run(() => muteMutation.mutateAsync({ accountId, mutedUntil: null })),
195
+ };
196
+ }
197
+
198
+ export type CommunityReportResolution = "resolve" | "resolve_and_hide" | "dismiss";
199
+
200
+ export interface UseCommunityModerationOptions {
201
+ /** Log page size. Default 20. */
202
+ logPageSize?: number;
203
+ }
204
+
205
+ /**
206
+ * Headless Moderation tab: the open reports, the posts and replies held for
207
+ * approval, and the moderation log (paged), with every action on them. Loads
208
+ * nothing until the viewer is known to moderate this community. `busyId` is the
209
+ * row an action is running on, so a renderer can disable just that row.
210
+ */
211
+ export function useCommunityModeration(opts: UseCommunityModerationOptions = {}) {
212
+ const signedIn = useIsSignedIn();
213
+ const me = useMyCommunityProfile(signedIn);
214
+ const canModerate = signedIn && !!me.data?.canModerate;
215
+ const [logPage, setLogPage] = useState(1);
216
+ const queue = useCommunityModerationQueue(canModerate);
217
+ const log = useCommunityModerationLog(logPage, opts.logPageSize ?? 20, canModerate);
218
+
219
+ const resolve = useResolveCommunityReport();
220
+ const postMutation = useModerateCommunityPost();
221
+ const replyMutation = useModerateCommunityReply();
222
+
223
+ const [busyId, setBusyId] = useState<string | null>(null);
224
+ const [error, setError] = useState<{ id: string; message: string | null } | null>(null);
225
+
226
+ const run = async (id: string, call: () => Promise<unknown>): Outcome => {
227
+ setBusyId(id);
228
+ setError(null);
229
+ try {
230
+ await call();
231
+ return true;
232
+ } catch (err) {
233
+ setError({ id, message: moderationErrorMessage(err) });
234
+ return false;
235
+ } finally {
236
+ setBusyId(null);
237
+ }
238
+ };
239
+
240
+ const logEntries = useMemo(() => log.data?.data ?? [], [log.data]);
241
+ const restorable = useMemo(() => restorableLogEntries(logEntries), [logEntries]);
242
+
243
+ return {
244
+ canModerate,
245
+ /** Still finding out whether the viewer moderates this community. */
246
+ isChecking: signedIn && me.isLoading,
247
+ /** The queue's first load. */
248
+ isLoading: canModerate && queue.isLoading,
249
+ reports: queue.data?.reports.data ?? [],
250
+ reportsTotal: queue.data?.reports.total ?? 0,
251
+ pendingPosts: queue.data?.pending.posts ?? [],
252
+ pendingReplies: queue.data?.pending.replies ?? [],
253
+ queueError: queue.isError,
254
+ busyId,
255
+ /** The last failed action: which row, and the server's message when it sent one. */
256
+ error,
257
+ clearError: () => setError(null),
258
+
259
+ resolveReport: (reportId: string, resolution: CommunityReportResolution) =>
260
+ run(reportId, () =>
261
+ resolve.mutateAsync({
262
+ reportId,
263
+ action: resolution === "dismiss" ? "dismissed" : "resolved",
264
+ hideContent: resolution === "resolve_and_hide",
265
+ }),
266
+ ),
267
+ approvePost: (item: Pick<CommunityPendingPost, "id">) =>
268
+ run(item.id, () => postMutation.mutateAsync({ postId: item.id, action: "approve" })),
269
+ rejectPost: (item: Pick<CommunityPendingPost, "id">, reason?: string) =>
270
+ run(item.id, () => postMutation.mutateAsync({ postId: item.id, action: "reject", reason })),
271
+ approveReply: (item: Pick<CommunityPendingReply, "id" | "postId">) =>
272
+ run(item.id, () => replyMutation.mutateAsync({ replyId: item.id, postId: item.postId, action: "approve" })),
273
+ rejectReply: (item: Pick<CommunityPendingReply, "id" | "postId">, reason?: string) =>
274
+ run(item.id, () =>
275
+ replyMutation.mutateAsync({ replyId: item.id, postId: item.postId, action: "reject", reason }),
276
+ ),
277
+
278
+ log: {
279
+ entries: logEntries,
280
+ page: logPage,
281
+ total: log.data?.total ?? 0,
282
+ hasNextPage: !!log.data?.hasNextPage,
283
+ hasPreviousPage: logPage > 1,
284
+ isLoading: canModerate && log.isLoading,
285
+ nextPage: () => setLogPage((p) => p + 1),
286
+ previousPage: () => setLogPage((p) => Math.max(1, p - 1)),
287
+ /** Entries that can still be undone, by entry id. */
288
+ restorable,
289
+ restore: (entry: CommunityModerationLogEntry) => {
290
+ const target = restorable.get(entry.id);
291
+ if (!target) return Promise.resolve(false);
292
+ return run(entry.id, () =>
293
+ target.type === "post"
294
+ ? postMutation.mutateAsync({ postId: target.id, action: "restore" })
295
+ : replyMutation.mutateAsync({ replyId: target.id, action: "restore" }),
296
+ );
297
+ },
298
+ },
299
+ };
300
+ }
@@ -2,6 +2,7 @@ import { useEffect, useMemo } from "react";
2
2
  import { useQueryClient } from "@tanstack/react-query";
3
3
  import { usePublicAuth } from "../../../contexts/PublicAuthContext";
4
4
  import {
5
+ applyCommunityContentHeld,
5
6
  applyReactionDelta,
6
7
  bumpFeedReplyCount,
7
8
  prependPostToFeed,
@@ -12,10 +13,12 @@ import {
12
13
  useJoinCommunitySpace,
13
14
  useLeaveCommunitySpace,
14
15
  useToggleCommunityReaction,
16
+ type CommunityContentHeldEvent,
15
17
  type CommunityPost,
16
18
  type CommunityReactionEvent,
17
19
  type CommunityReply,
18
20
  } from "../../../data/queries/useCommunity";
21
+ import { useCommunityAgentThinking } from "./useCommunityAgent";
19
22
 
20
23
  /**
21
24
  * Headless space feed: the space (from the gated spaces list), its paginated
@@ -25,7 +28,7 @@ import {
25
28
  *
26
29
  * The single primitive a creator needs to rebuild a space feed page.
27
30
  *
28
- * `idOrSlug` accepts either a space uuid or its per-profile slug — the space is
31
+ * `idOrSlug` accepts either a space uuid or its per-profile slug: the space is
29
32
  * resolved from the gated spaces list by id OR slug, so slug-addressed deep
30
33
  * links load the right space without a separate lookup.
31
34
  */
@@ -39,9 +42,12 @@ export function useCommunitySpaceFeed(idOrSlug?: string) {
39
42
  [spaces, idOrSlug],
40
43
  );
41
44
  // Downstream hooks (posts, realtime room, create) are keyed by the canonical
42
- // space uuid — only wire them up once the space resolves from the list, so a
45
+ // space uuid, so only wire them up once the space resolves from the list, so a
43
46
  // slug-addressed link never leaks a slug into a uuid-keyed request/room.
44
- const spaceId = space?.id;
47
+ // A space above the member's level is listed but closed: nothing is asked
48
+ // for it (the server would refuse), and no realtime room is joined.
49
+ const levelLocked = !!space?.levelLocked;
50
+ const spaceId = levelLocked ? undefined : space?.id;
45
51
 
46
52
  const {
47
53
  data: postPages,
@@ -80,6 +86,10 @@ export function useCommunitySpaceFeed(idOrSlug?: string) {
80
86
  if (data.event === "communityReaction" && data.data?.entityId && data.data?.accountId !== user?.id) {
81
87
  applyReactionDelta(queryClient, data.data as CommunityReactionEvent);
82
88
  }
89
+ // Auto-moderation sent it back to the approval queue: out of the open feed.
90
+ if (data.event === "communityContentHeld" && data.data?.spaceId === spaceId) {
91
+ applyCommunityContentHeld(queryClient, data.data as unknown as CommunityContentHeldEvent, user?.id);
92
+ }
83
93
  };
84
94
  socket.on("message", onMessage);
85
95
  return () => {
@@ -87,6 +97,9 @@ export function useCommunitySpaceFeed(idOrSlug?: string) {
87
97
  };
88
98
  }, [socket, spaceId, queryClient, user?.id]);
89
99
 
100
+ // The community AI answering a post in this space (cleared by the answer).
101
+ const agentThinking = useCommunityAgentThinking(socket);
102
+
90
103
  // Join/leave the space room (mirrors chatJoinChannel).
91
104
  useEffect(() => {
92
105
  if (!spaceId || !socket) return;
@@ -108,7 +121,12 @@ export function useCommunitySpaceFeed(idOrSlug?: string) {
108
121
  fetchNextPage,
109
122
  hasMore: !!hasNextPage && !isFetchingNextPage,
110
123
  currentAccountId: user?.id,
111
- // Belonging — tiers govern access, join governs new-post notifications.
124
+ /** Whether the community AI is writing an answer under this post right now. */
125
+ isAgentThinking: agentThinking.isThinking,
126
+ /** The space is above the member's level: show it locked, not its feed. */
127
+ levelLocked,
128
+ minLevel: space?.minLevel ?? 1,
129
+ // Belonging: tiers govern access, join governs new-post notifications.
112
130
  isJoined: space?.isJoined ?? false,
113
131
  memberCount: space?.memberCount ?? null,
114
132
  joinPending: joinSpace.isPending || leaveSpace.isPending,
@@ -2,6 +2,7 @@ import { useEffect, useMemo } from "react";
2
2
  import { useQueryClient } from "@tanstack/react-query";
3
3
  import { usePublicAuth } from "../../../contexts/PublicAuthContext";
4
4
  import {
5
+ applyCommunityContentHeld,
5
6
  appendReplyToThread,
6
7
  applyReactionDelta,
7
8
  useCommunityPost,
@@ -9,15 +10,17 @@ import {
9
10
  useCommunitySocket,
10
11
  useCreateCommunityReply,
11
12
  useToggleCommunityReaction,
13
+ type CommunityContentHeldEvent,
12
14
  type CommunityReactionEvent,
13
15
  type CommunityReply,
14
16
  type ReactableEntityType,
15
17
  } from "../../../data/queries/useCommunity";
16
18
  import { buildReplyTree } from "./replyTree";
19
+ import { useCommunityAgentThinking } from "./useCommunityAgent";
17
20
 
18
21
  /**
19
22
  * Headless post thread: the post, its full reply tree (assembled from the flat
20
- * pages — data nesting is unlimited, display capping is the renderer's job),
23
+ * pages: data nesting is unlimited, display capping is the renderer's job),
21
24
  * realtime reply/reaction updates via the post's space room, plus reply and
22
25
  * toggle-reaction actions.
23
26
  */
@@ -52,13 +55,18 @@ export function useCommunityThread(postId?: string) {
52
55
  data?: { spaceId?: string; postId?: string; reply?: CommunityReply } & Partial<CommunityReactionEvent>;
53
56
  }) => {
54
57
  if (data.event === "communityNewReply" && data.data?.postId === postId && data.data?.reply) {
55
- // Skip own replies — the mutation already appended them.
56
- if (data.data.reply.authorAccountId === user?.id) return;
58
+ // Skip own replies: the mutation already appended them. An answer from
59
+ // the community AI has no account, so it is never skipped.
60
+ if (user?.id && data.data.reply.authorAccountId === user.id) return;
57
61
  appendReplyToThread(queryClient, postId, data.data.reply);
58
62
  }
59
63
  if (data.event === "communityReaction" && data.data?.entityId && data.data?.accountId !== user?.id) {
60
64
  applyReactionDelta(queryClient, data.data as CommunityReactionEvent, { postId });
61
65
  }
66
+ // Auto-moderation held this post, or a reply in it: out of the open thread.
67
+ if (data.event === "communityContentHeld" && data.data?.postId === postId) {
68
+ applyCommunityContentHeld(queryClient, data.data as unknown as CommunityContentHeldEvent, user?.id);
69
+ }
62
70
  };
63
71
  socket.on("message", onMessage);
64
72
  return () => {
@@ -66,6 +74,9 @@ export function useCommunityThread(postId?: string) {
66
74
  };
67
75
  }, [socket, postId, queryClient, user?.id]);
68
76
 
77
+ // The community AI writing an answer in this thread (cleared by the answer).
78
+ const agentThinking = useCommunityAgentThinking(socket, (id) => id === postId);
79
+
69
80
  useEffect(() => {
70
81
  if (!spaceId || !socket) return;
71
82
  const join = () => socket.emit("communityJoinSpace", { spaceId });
@@ -87,6 +98,8 @@ export function useCommunityThread(postId?: string) {
87
98
  fetchNextPage,
88
99
  hasMore: !!hasNextPage && !isFetchingNextPage,
89
100
  currentAccountId: user?.id,
101
+ /** Whether the community AI is writing an answer in this thread right now. */
102
+ agentThinking: !!postId && agentThinking.isThinking(postId),
90
103
  // Actions
91
104
  createReply,
92
105
  react: (entityType: Extract<ReactableEntityType, "community_post" | "community_reply">, entityId: string, emoji: string) =>
@@ -7,10 +7,16 @@ import {
7
7
  import { buildLexicalState, mentionsInText, type MentionRef } from "./lexical";
8
8
 
9
9
  export interface MentionSuggestion {
10
+ /** The member's account. Empty for the community AI, which has none. */
10
11
  accountId: string;
11
12
  name: string;
12
13
  avatarUrl: string | null;
13
14
  level: number;
15
+ /**
16
+ * Set when this suggestion is the community AI rather than a member. It is
17
+ * listed first and inserts an agent mention (`agentId`, never `accountId`).
18
+ */
19
+ agentId?: string | null;
14
20
  }
15
21
 
16
22
  /**
@@ -24,7 +30,17 @@ export function useMentionComposer(initialText = "") {
24
30
  const [text, setText] = useState(initialText);
25
31
  const [caret, setCaret] = useState(initialText.length);
26
32
  const [trackedMentions, setTrackedMentions] = useState<MentionRef[]>([]);
27
- const { data: directory = [] } = useCommunityMentionDirectory();
33
+
34
+ /** The active "@query" token immediately before the caret, if any. */
35
+ const activeToken = useMemo(() => {
36
+ const upToCaret = text.slice(0, caret);
37
+ const match = /(^|\s)@([^\s@]{0,40})$/.exec(upToCaret);
38
+ if (!match) return null;
39
+ return { query: match[2], start: caret - match[2].length - 1 };
40
+ }, [text, caret]);
41
+
42
+ // Searched on the server as the member types; nothing is asked for without an @token.
43
+ const { data: directory = [] } = useCommunityMentionDirectory(activeToken?.query);
28
44
 
29
45
  const members: MentionSuggestion[] = useMemo(
30
46
  () =>
@@ -37,14 +53,6 @@ export function useMentionComposer(initialText = "") {
37
53
  [directory],
38
54
  );
39
55
 
40
- /** The active "@query" token immediately before the caret, if any. */
41
- const activeToken = useMemo(() => {
42
- const upToCaret = text.slice(0, caret);
43
- const match = /(^|\s)@([^\s@]{0,40})$/.exec(upToCaret);
44
- if (!match) return null;
45
- return { query: match[2], start: caret - match[2].length - 1 };
46
- }, [text, caret]);
47
-
48
56
  const suggestions: MentionSuggestion[] = useMemo(() => {
49
57
  if (!activeToken) return [];
50
58
  const q = activeToken.query.toLowerCase();
@@ -4,6 +4,7 @@ import {
4
4
  useCommunityMentionDirectory,
5
5
  type CommunityLeaderboardEntry,
6
6
  } from "../../../data/queries/useCommunity";
7
+ import { useCommunityAgent } from "../../../data/queries/useCommunityAgent";
7
8
  import type { MentionSuggestion } from "./useMentionComposer";
8
9
  import { collectMentions, isRichEmpty, serializeRichToLexical } from "./richLexical";
9
10
 
@@ -35,7 +36,10 @@ export function useRichMentionComposer() {
35
36
  const [tick, setTick] = useState(0);
36
37
  const bump = useCallback(() => setTick((n) => n + 1), []);
37
38
 
38
- const { data: directory = [] } = useCommunityMentionDirectory();
39
+ // Searched on the server as the member types; nothing is asked for without an @token.
40
+ const { data: directory = [] } = useCommunityMentionDirectory(activeToken?.query);
41
+ // The community AI, listed first when it is on (community parity §8.2).
42
+ const { agent } = useCommunityAgent();
39
43
 
40
44
  const members: MentionSuggestion[] = useMemo(
41
45
  () =>
@@ -51,8 +55,17 @@ export function useRichMentionComposer() {
51
55
  const suggestions: MentionSuggestion[] = useMemo(() => {
52
56
  if (!activeToken) return [];
53
57
  const q = activeToken.query.toLowerCase();
54
- return members.filter((m) => m.name.toLowerCase().includes(q)).slice(0, 6);
55
- }, [activeToken, members]);
58
+ const matched = members.filter((m) => m.name.toLowerCase().includes(q)).slice(0, 6);
59
+ if (!agent || !agent.name.toLowerCase().includes(q)) return matched;
60
+ const agentSuggestion: MentionSuggestion = {
61
+ accountId: "",
62
+ agentId: agent.id,
63
+ name: agent.name,
64
+ avatarUrl: agent.avatarUrl,
65
+ level: 0,
66
+ };
67
+ return [agentSuggestion, ...matched];
68
+ }, [activeToken, members, agent]);
56
69
 
57
70
  /** Recompute the active @query token from the current selection. */
58
71
  const detectToken = useCallback(() => {
@@ -74,7 +87,7 @@ export function useRichMentionComposer() {
74
87
  setActiveToken({ query, textNode, atOffset: caretOffset - query.length - 1, caretOffset });
75
88
  }, []);
76
89
 
77
- /** Fired on input/keyup/click/select — refresh emptiness + token. */
90
+ /** Fired on input/keyup/click/select: refresh emptiness + token. */
78
91
  const onEditorChange = useCallback(() => {
79
92
  bump();
80
93
  detectToken();
@@ -124,7 +137,8 @@ export function useRichMentionComposer() {
124
137
  range.deleteContents();
125
138
 
126
139
  const chip = doc.createElement("span");
127
- chip.setAttribute("data-account-id", member.accountId);
140
+ if (member.agentId) chip.setAttribute("data-agent-id", member.agentId);
141
+ else chip.setAttribute("data-account-id", member.accountId);
128
142
  chip.setAttribute("data-mention-name", member.name);
129
143
  chip.setAttribute("contenteditable", "false");
130
144
  chip.textContent = `@${member.name}`;
@@ -184,6 +198,8 @@ export function useRichMentionComposer() {
184
198
  isSuggesting: !!activeToken && suggestions.length > 0,
185
199
  insertMention,
186
200
  dismissSuggestions: () => setActiveToken(null),
201
+ /** The community AI when it is on and can be mentioned, else null. */
202
+ agent,
187
203
  // Submission / state
188
204
  isEmpty,
189
205
  getBody,
@@ -0,0 +1,8 @@
1
+ // The download page behind a digital delivery email (`/i/downloads/$token`).
2
+ export {
3
+ useDownloadPage,
4
+ type DownloadItem,
5
+ type DownloadFile,
6
+ type DownloadFileInfo,
7
+ type DownloadPageInfo,
8
+ } from "./useDownloadPage";