@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,7 +1,7 @@
1
1
  // DOM → Lexical-state serializer for the rich community composer. The
2
2
  // contentEditable div is the source of truth; here we walk its DOM and emit the
3
3
  // exact Lexical JSON shapes CommunityRichText renders and the create-post API
4
- // stores — WITHOUT pulling the Lexical runtime into forge. Mirrors the node
4
+ // stores, WITHOUT pulling the Lexical runtime into forge. Mirrors the node
5
5
  // shapes in ./lexical.ts (buildLexicalState) so plain-text and rich content
6
6
  // round-trip through the same renderer.
7
7
 
@@ -41,6 +41,23 @@ const mentionNode = (accountId: string, name: string): LexicalNodeJson => ({
41
41
  style: "",
42
42
  });
43
43
 
44
+ /**
45
+ * A mention of the community AI. It carries `agentId` and NO account field: the
46
+ * backend reads an account from `accountId | id | mentionId`, and the agent has
47
+ * no account, so it would otherwise be read as a member mention.
48
+ */
49
+ const agentMentionNode = (agentId: string, name: string): LexicalNodeJson => ({
50
+ type: "mention",
51
+ version: 1,
52
+ agentId,
53
+ mentionName: name,
54
+ text: `@${name}`,
55
+ detail: 0,
56
+ format: 0,
57
+ mode: "segmented",
58
+ style: "",
59
+ });
60
+
44
61
  const linebreakNode = (): LexicalNodeJson => ({ type: "linebreak", version: 1 });
45
62
 
46
63
  const linkNode = (url: string, children: LexicalNodeJson[]): LexicalNodeJson => ({
@@ -177,6 +194,14 @@ function serializeInline(nodes: NodeListOf<ChildNode> | ChildNode[], inherited:
177
194
  return;
178
195
  }
179
196
 
197
+ // The community AI's chip: a non-editable span carrying the agent id.
198
+ const agentId = node.getAttribute("data-agent-id");
199
+ if (agentId) {
200
+ const name = (node.getAttribute("data-mention-name") ?? node.textContent ?? "").replace(/^@/, "");
201
+ push(agentMentionNode(agentId, name));
202
+ return;
203
+ }
204
+
180
205
  // Mention chip: a non-editable span carrying the account id.
181
206
  const accountId = node.getAttribute("data-account-id");
182
207
  if (accountId) {
@@ -309,6 +334,6 @@ export function collectMentions(root: HTMLElement): MentionRef[] {
309
334
 
310
335
  /** Whether the editor holds any real content (text or a mention chip). */
311
336
  export function isRichEmpty(root: HTMLElement): boolean {
312
- if (root.querySelector("[data-account-id]")) return false;
337
+ if (root.querySelector("[data-account-id], [data-agent-id]")) return false;
313
338
  return (root.textContent ?? "").trim().length === 0;
314
339
  }
@@ -0,0 +1,285 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
+ import type { Socket } from "socket.io-client";
3
+ import {
4
+ communityAgentSummaryError,
5
+ useCommunityAgentFeedback,
6
+ useCommunityAgentRatings,
7
+ useCommunityAgentSummary,
8
+ type CommunityAgentFeedbackEntity,
9
+ type CommunityAgentFeedbackValue,
10
+ type CommunityAgentSummary,
11
+ type CommunityAgentSummaryError,
12
+ } from "../../../data/queries/useCommunityAgent";
13
+ import type { CommunityReply } from "../../../data/queries/useCommunity";
14
+ import type { ChatMessage } from "../../../data/queries/useChat";
15
+
16
+ /**
17
+ * How long a "thinking" indicator may stand without its `done`. The answer job
18
+ * always sends `done`, but a dropped socket would otherwise leave the
19
+ * indicator up for ever.
20
+ */
21
+ export const AGENT_THINKING_TIMEOUT_MS = 120_000;
22
+
23
+ /** The reply count from which a thread offers "Catch me up". */
24
+ export const CATCH_ME_UP_MIN_REPLIES = 10;
25
+
26
+ // ── Linkified text ───────────────────────────────────────────────────────────
27
+
28
+ export type LinkifiedSegment = { kind: "text"; text: string } | { kind: "link"; text: string; url: string };
29
+
30
+ /**
31
+ * A titled link, `[Title](https://...)`, or a bare http(s) address. The titled
32
+ * form is how the agent writes its sources; bare addresses are kept for
33
+ * answers written before it, and for addresses inside the answer itself.
34
+ */
35
+ const LINK_RE = /\[([^\]\n]+)\]\((https?:\/\/[^\s)]+)\)|https?:\/\/[^\s<>"')\]]+/g;
36
+ /** Punctuation that ends a sentence rather than the address. */
37
+ const TRAILING_PUNCTUATION = /[.,;:!?]+$/;
38
+
39
+ /**
40
+ * Split plain text into text and http(s) link segments. The agent writes its
41
+ * answers as plain text with a "Sources:" list of titled links; this is what
42
+ * makes them clickable without trusting any markup. Only http(s) addresses
43
+ * ever become links. Pure.
44
+ */
45
+ export function linkifyText(text: string): LinkifiedSegment[] {
46
+ const out: LinkifiedSegment[] = [];
47
+ let last = 0;
48
+ for (const match of text.matchAll(LINK_RE)) {
49
+ const start = match.index ?? 0;
50
+ if (start > last) out.push({ kind: "text", text: text.slice(last, start) });
51
+ if (match[1] !== undefined && match[2] !== undefined) {
52
+ out.push({ kind: "link", text: match[1], url: match[2] });
53
+ last = start + match[0].length;
54
+ continue;
55
+ }
56
+ let url = match[0];
57
+ const trailing = TRAILING_PUNCTUATION.exec(url)?.[0] ?? "";
58
+ if (trailing) url = url.slice(0, -trailing.length);
59
+ out.push({ kind: "link", text: url, url });
60
+ last = start + url.length;
61
+ }
62
+ if (last < text.length) out.push({ kind: "text", text: text.slice(last) });
63
+ return out;
64
+ }
65
+
66
+ // ── Who asked ────────────────────────────────────────────────────────────────
67
+
68
+ /**
69
+ * The viewer's ratings on the AI answers in one visible list, and with them
70
+ * who is offered thumbs at all: the read returns ONLY the answers this account
71
+ * asked for, so an answer is ratable exactly when its id came back. Pass the
72
+ * ids of the agent's answers in the list; nothing is asked for when there
73
+ * are none, when signed out, or when the AI is off.
74
+ */
75
+ export function useCommunityAgentAnswerRatings(entityType: CommunityAgentFeedbackEntity, answerIds: string[]) {
76
+ const { data } = useCommunityAgentRatings(entityType, answerIds);
77
+ const byId = useMemo(() => new Map((data ?? []).map((row) => [row.entityId, row.feedback])), [data]);
78
+ return {
79
+ /** The viewer asked this question, so they may rate the answer. */
80
+ canRate: (entityId: string) => byId.has(entityId),
81
+ /** Their saved rating, or null. */
82
+ ratingOf: (entityId: string): 1 | -1 | null => byId.get(entityId) ?? null,
83
+ };
84
+ }
85
+
86
+ // ── Thinking ────────────────────────────────────────────────────────────────
87
+
88
+ interface SocketEnvelope {
89
+ event?: string;
90
+ data?: Record<string, unknown> & {
91
+ postId?: string;
92
+ state?: string;
93
+ reply?: CommunityReply;
94
+ channelId?: string;
95
+ parentMessageId?: string | null;
96
+ message?: ChatMessage;
97
+ };
98
+ }
99
+
100
+ /** A set of keys that each clear themselves after `AGENT_THINKING_TIMEOUT_MS`. */
101
+ function useExpiringSet() {
102
+ const [keys, setKeys] = useState<ReadonlySet<string>>(() => new Set());
103
+ const timers = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
104
+
105
+ const remove = useCallback((key: string) => {
106
+ const timer = timers.current.get(key);
107
+ if (timer) clearTimeout(timer);
108
+ timers.current.delete(key);
109
+ setKeys((prev) => {
110
+ if (!prev.has(key)) return prev;
111
+ const next = new Set(prev);
112
+ next.delete(key);
113
+ return next;
114
+ });
115
+ }, []);
116
+
117
+ const add = useCallback(
118
+ (key: string) => {
119
+ const timer = timers.current.get(key);
120
+ if (timer) clearTimeout(timer);
121
+ timers.current.set(
122
+ key,
123
+ setTimeout(() => remove(key), AGENT_THINKING_TIMEOUT_MS),
124
+ );
125
+ setKeys((prev) => (prev.has(key) ? prev : new Set(prev).add(key)));
126
+ },
127
+ [remove],
128
+ );
129
+
130
+ useEffect(() => {
131
+ const map = timers.current;
132
+ return () => {
133
+ map.forEach((timer) => clearTimeout(timer));
134
+ map.clear();
135
+ };
136
+ }, []);
137
+
138
+ return { keys, add, remove };
139
+ }
140
+
141
+ /**
142
+ * Which posts the community AI is answering right now, from the space room's
143
+ * `communityAgentThinking` events (the room is joined by the feed or thread
144
+ * hook). An entry clears on `done`, when the answer itself arrives as a
145
+ * `communityNewReply`, or after `AGENT_THINKING_TIMEOUT_MS`.
146
+ *
147
+ * `accepts` limits it to the posts the caller shows (one post in a thread).
148
+ */
149
+ export function useCommunityAgentThinking(socket: Socket | null, accepts?: (postId: string) => boolean) {
150
+ const { keys, add, remove } = useExpiringSet();
151
+ const acceptsRef = useRef(accepts);
152
+ acceptsRef.current = accepts;
153
+
154
+ useEffect(() => {
155
+ if (!socket) return;
156
+ const onMessage = (envelope: SocketEnvelope) => {
157
+ const postId = envelope.data?.postId;
158
+ if (!postId || (acceptsRef.current && !acceptsRef.current(postId))) return;
159
+ if (envelope.event === "communityAgentThinking") {
160
+ if (envelope.data?.state === "thinking") add(postId);
161
+ else remove(postId);
162
+ }
163
+ if (envelope.event === "communityNewReply" && envelope.data?.reply?.agentAuthor) remove(postId);
164
+ };
165
+ socket.on("message", onMessage);
166
+ return () => {
167
+ socket.off("message", onMessage);
168
+ };
169
+ }, [socket, add, remove]);
170
+
171
+ return {
172
+ thinkingPostIds: keys,
173
+ isThinking: (postId: string) => keys.has(postId),
174
+ };
175
+ }
176
+
177
+ /** The key one chat thinking indicator lives under: the thread, or the channel itself. */
178
+ export const chatThinkingKey = (channelId: string, parentMessageId: string | null | undefined) =>
179
+ `${channelId}:${parentMessageId ?? ""}`;
180
+
181
+ /**
182
+ * Where the community AI is answering in chat, from `chatAgentThinking` on the
183
+ * channel rooms the chat joined. A channel answer goes in the tagging
184
+ * message's thread (`parentMessageId` set); a DM answer goes in the DM itself
185
+ * (`parentMessageId` null). Clears on `done`, on the agent's message arriving,
186
+ * or after `AGENT_THINKING_TIMEOUT_MS`.
187
+ */
188
+ export function useChatAgentThinking(socket: Socket | null) {
189
+ const { keys, add, remove } = useExpiringSet();
190
+
191
+ useEffect(() => {
192
+ if (!socket) return;
193
+ const onMessage = (envelope: SocketEnvelope) => {
194
+ const channelId = envelope.data?.channelId;
195
+ if (!channelId) return;
196
+ if (envelope.event === "chatAgentThinking") {
197
+ const key = chatThinkingKey(channelId, envelope.data?.parentMessageId ?? null);
198
+ if (envelope.data?.state === "thinking") add(key);
199
+ else remove(key);
200
+ }
201
+ const message = envelope.data?.message;
202
+ if (envelope.event === "chatNewMessage" && message?.senderAgentId) {
203
+ remove(chatThinkingKey(channelId, message.parentMessageId ?? null));
204
+ }
205
+ };
206
+ socket.on("message", onMessage);
207
+ return () => {
208
+ socket.off("message", onMessage);
209
+ };
210
+ }, [socket, add, remove]);
211
+
212
+ return {
213
+ isThinking: (channelId: string | null | undefined, parentMessageId: string | null | undefined) =>
214
+ !!channelId && keys.has(chatThinkingKey(channelId, parentMessageId ?? null)),
215
+ };
216
+ }
217
+
218
+ // ── Feedback ─────────────────────────────────────────────────────────────────
219
+
220
+ /**
221
+ * Thumbs up / down on one AI answer, optimistic. `saved` is the rating the
222
+ * ratings read hydrated, so the thumbs survive a reload; a press shows at once,
223
+ * and a success lands in the ratings cache. Choosing the active thumb again
224
+ * clears it. A failed rating rolls back; a 404 (not the member who asked, a
225
+ * safety net now the ratings read decides who sees the control) hides it.
226
+ */
227
+ export function useCommunityAgentAnswerFeedback(
228
+ entityType: CommunityAgentFeedbackEntity,
229
+ entityId: string,
230
+ saved: 1 | -1 | null = null,
231
+ ) {
232
+ const mutation = useCommunityAgentFeedback();
233
+ // `undefined` = follow the saved rating; set while a press is in flight or failed back.
234
+ const [local, setLocal] = useState<1 | -1 | null | undefined>(undefined);
235
+ const [hidden, setHidden] = useState(false);
236
+ const value = local !== undefined ? local : saved;
237
+
238
+ const rate = useCallback(
239
+ (thumb: 1 | -1) => {
240
+ const previous = value;
241
+ const next: CommunityAgentFeedbackValue = previous === thumb ? 0 : thumb;
242
+ setLocal(next === 0 ? null : next);
243
+ mutation.mutate(
244
+ { entityType, entityId, value: next },
245
+ {
246
+ onError: (error) => {
247
+ setLocal(previous);
248
+ if ((error as { response?: { status?: number } })?.response?.status === 404) setHidden(true);
249
+ },
250
+ },
251
+ );
252
+ },
253
+ [value, mutation, entityType, entityId],
254
+ );
255
+
256
+ return { value, hidden, rate, isPending: mutation.isPending };
257
+ }
258
+
259
+ // ── Catch me up ──────────────────────────────────────────────────────────────
260
+
261
+ /**
262
+ * "Catch me up" on a long thread: ask, then show the private summary until the
263
+ * member dismisses it. `error` is `daily_limit` (409), `unavailable` (400: too
264
+ * short, or the AI cannot answer now) or `failed`.
265
+ */
266
+ export function useCommunityCatchUp(postId?: string) {
267
+ const mutation = useCommunityAgentSummary(postId);
268
+ const [summary, setSummary] = useState<CommunityAgentSummary | null>(null);
269
+ const [error, setError] = useState<CommunityAgentSummaryError | null>(null);
270
+
271
+ const ask = useCallback(() => {
272
+ setError(null);
273
+ mutation.mutate(undefined, {
274
+ onSuccess: (data) => setSummary(data),
275
+ onError: (e) => setError(communityAgentSummaryError(e)),
276
+ });
277
+ }, [mutation]);
278
+
279
+ const dismiss = useCallback(() => {
280
+ setSummary(null);
281
+ setError(null);
282
+ }, []);
283
+
284
+ return { summary, error, ask, dismiss, isPending: mutation.isPending };
285
+ }
@@ -1,6 +1,7 @@
1
1
  import axios from "axios";
2
2
  import { useCallback, useRef, useState } from "react";
3
3
  import { useForge } from "../../../provider/ForgeProvider";
4
+ import { useOptionalCommunityContext } from "../../../contexts/CommunityContext";
4
5
  import { attachmentKind, type AttachmentKind } from "../chat/attachmentKind";
5
6
  import type { CommunityAttachmentInput } from "../../../data/queries/useCommunity";
6
7
 
@@ -31,11 +32,14 @@ const toApiType = (kind: AttachmentKind): CommunityAttachmentInput["type"] => {
31
32
  /**
32
33
  * Headless attachment composer for community posts/replies: stage local files
33
34
  * (with previews), upload to R2 via the member-scoped presigned endpoint
34
- * (POST /public/community/attachments/presigned-url — same flow as chat's
35
+ * (POST /public/community/attachments/presigned-url: same flow as chat's
35
36
  * useChatAttachmentUpload), and hand back `CommunityAttachmentInput[]`.
36
37
  */
37
38
  export function useCommunityAttachmentUpload() {
38
39
  const { client, profileId } = useForge();
40
+ // The community the attachment is posted into, when there is one. Soft: the
41
+ // composer also serves course spaces, which belong to no community.
42
+ const communityId = useOptionalCommunityContext()?.communityId;
39
43
  const [pending, setPending] = useState<PendingCommunityAttachment[]>([]);
40
44
  const [error, setError] = useState<string | null>(null);
41
45
  const pendingRef = useRef<PendingCommunityAttachment[]>([]);
@@ -96,7 +100,7 @@ export function useCommunityAttachmentUpload() {
96
100
  try {
97
101
  const { data } = await client.post<{ presignedUrl: string; remoteUrl: string }>(
98
102
  "/public/community/attachments/presigned-url",
99
- { fileName: item.file.name, profileId },
103
+ { fileName: item.file.name, profileId, ...(communityId ? { communityId } : {}) },
100
104
  );
101
105
  await axios.put(data.presignedUrl, item.file, {
102
106
  headers: { "Content-Type": item.file.type || "application/octet-stream" },
@@ -121,7 +125,7 @@ export function useCommunityAttachmentUpload() {
121
125
  }
122
126
 
123
127
  return uploaded;
124
- }, [client, profileId, patch]);
128
+ }, [client, profileId, communityId, patch]);
125
129
 
126
130
  return {
127
131
  pending,
@@ -0,0 +1,175 @@
1
+ import { useEffect, useMemo, useState } from "react";
2
+ import {
3
+ useCommunityDirectory,
4
+ useCommunityMemberMap,
5
+ type CommunityDirectoryMember,
6
+ type CommunityDirectoryRole,
7
+ type CommunityDirectorySort,
8
+ type CommunityMemberMapCountry,
9
+ } from "../../../data/queries/useCommunityMembers";
10
+ import { COUNTRY_CENTROIDS, countryName } from "./countries";
11
+
12
+ // ── Pure helpers (unit-tested; no React) ─────────────────────────────────────
13
+
14
+ export const DIRECTORY_SORTS: readonly CommunityDirectorySort[] = ["active", "points", "newest", "name"];
15
+ export const DIRECTORY_LEVELS: readonly number[] = [1, 2, 3, 4, 5, 6, 7, 8, 9];
16
+
17
+ /** One dot on the member globe: a country, where it is, and how many members. */
18
+ export interface MemberGlobePoint {
19
+ countryCode: string;
20
+ latitude: number;
21
+ longitude: number;
22
+ members: number;
23
+ }
24
+
25
+ /** A dot as the globe library draws it. */
26
+ export interface MemberGlobeMarker {
27
+ location: [number, number];
28
+ size: number;
29
+ }
30
+
31
+ /**
32
+ * The member map's countries, cleaned: codes upper-cased, repeats merged,
33
+ * empty and malformed rows dropped, biggest first. Every country is here,
34
+ * including one the globe has no centre for, so the list under the globe
35
+ * leaves nobody out.
36
+ */
37
+ export function memberMapCountries(rows: CommunityMemberMapCountry[] | undefined): CommunityMemberMapCountry[] {
38
+ const merged = new Map<string, number>();
39
+ for (const row of Array.isArray(rows) ? rows : []) {
40
+ const code = typeof row.countryCode === "string" ? row.countryCode.trim().toUpperCase() : "";
41
+ const members = Number(row.members) || 0;
42
+ if (!/^[A-Z]{2}$/.test(code) || members <= 0) continue;
43
+ merged.set(code, (merged.get(code) ?? 0) + members);
44
+ }
45
+ return [...merged.entries()]
46
+ .map(([countryCode, members]) => ({ countryCode, members }))
47
+ .sort((a, b) => b.members - a.members || a.countryCode.localeCompare(b.countryCode));
48
+ }
49
+
50
+ /**
51
+ * The member map's countries as globe dots, placed at each country's centre.
52
+ * A country with no known centre has no dot (it still lists, through
53
+ * `memberMapCountries`). Biggest first.
54
+ */
55
+ export function memberGlobePoints(rows: CommunityMemberMapCountry[] | undefined): MemberGlobePoint[] {
56
+ const points: MemberGlobePoint[] = [];
57
+ for (const { countryCode, members } of memberMapCountries(rows)) {
58
+ const centre = COUNTRY_CENTROIDS[countryCode];
59
+ if (centre) points.push({ countryCode, latitude: centre[0], longitude: centre[1], members });
60
+ }
61
+ return points;
62
+ }
63
+
64
+ /**
65
+ * Dot sizes by the square root of each country's share of the biggest, so one
66
+ * large country does not hide the rest: 0.03 for the smallest to 0.12 for the
67
+ * biggest.
68
+ */
69
+ export function memberGlobeMarkers(points: MemberGlobePoint[]): MemberGlobeMarker[] {
70
+ const max = Math.max(1, ...points.map((p) => p.members));
71
+ return points.map((p) => ({ location: [p.latitude, p.longitude], size: 0.03 + 0.09 * Math.sqrt(p.members / max) }));
72
+ }
73
+
74
+ /** cobe's rotation that brings a place to the front. */
75
+ export function globeAnglesFor(latitude: number, longitude: number): [number, number] {
76
+ return [Math.PI - ((longitude * Math.PI) / 180 - Math.PI / 2), (latitude * Math.PI) / 180];
77
+ }
78
+
79
+ /** "Berlin, Germany", "Germany", "Berlin", or null: where a member says they are. */
80
+ export function memberLocationLabel(
81
+ member: Pick<CommunityDirectoryMember, "city" | "countryCode">,
82
+ locale = "en",
83
+ ): string | null {
84
+ const city = member.city?.trim() || null;
85
+ const country = countryName(member.countryCode, locale);
86
+ if (city && country) return `${city}, ${country}`;
87
+ return city ?? country;
88
+ }
89
+
90
+ // ── Hooks ────────────────────────────────────────────────────────────────────
91
+
92
+ /** How long the search box settles before the directory is asked. */
93
+ export const DIRECTORY_SEARCH_DEBOUNCE_MS = 300;
94
+
95
+ export type CommunityMembersView = "list" | "map";
96
+
97
+ export interface UseCommunityMemberDirectoryOptions {
98
+ /** Cards per page. Default 24. */
99
+ pageSize?: number;
100
+ initialView?: CommunityMembersView;
101
+ }
102
+
103
+ /**
104
+ * Headless member directory for the surrounding community: the search (sent
105
+ * once typing settles), role and level filters, sort, paging, and the list or
106
+ * map view. Changing any filter goes back to page 1. The map's data loads only
107
+ * once the map is shown.
108
+ */
109
+ export function useCommunityMemberDirectory(opts: UseCommunityMemberDirectoryOptions = {}) {
110
+ const [search, setSearch] = useState("");
111
+ const [q, setQ] = useState("");
112
+ const [role, setRoleState] = useState<CommunityDirectoryRole | null>(null);
113
+ const [minLevel, setMinLevelState] = useState<number | null>(null);
114
+ const [sort, setSortState] = useState<CommunityDirectorySort>("active");
115
+ const [page, setPage] = useState(1);
116
+ const [view, setView] = useState<CommunityMembersView>(opts.initialView ?? "list");
117
+
118
+ useEffect(() => {
119
+ const id = setTimeout(() => {
120
+ setQ(search.trim());
121
+ setPage(1);
122
+ }, DIRECTORY_SEARCH_DEBOUNCE_MS);
123
+ return () => clearTimeout(id);
124
+ }, [search]);
125
+
126
+ const directory = useCommunityDirectory({ q, role, minLevel, sort, page, limit: opts.pageSize });
127
+ const map = useCommunityMemberMap({ enabled: view === "map" });
128
+ const countries = useMemo(() => memberMapCountries(map.data), [map.data]);
129
+ const points = useMemo(() => memberGlobePoints(map.data), [map.data]);
130
+
131
+ const resetting =
132
+ <T>(set: (value: T) => void) =>
133
+ (value: T) => {
134
+ set(value);
135
+ setPage(1);
136
+ };
137
+
138
+ return {
139
+ // Filters
140
+ search,
141
+ setSearch,
142
+ role,
143
+ setRole: resetting(setRoleState),
144
+ minLevel,
145
+ setMinLevel: resetting(setMinLevelState),
146
+ sort,
147
+ setSort: resetting(setSortState),
148
+ /** Any filter other than the default order is on. */
149
+ filtered: !!q || !!role || !!minLevel,
150
+ // Results
151
+ members: directory.data?.data ?? [],
152
+ total: directory.data?.total ?? 0,
153
+ page,
154
+ hasNextPage: !!directory.data?.hasNextPage,
155
+ hasPreviousPage: page > 1,
156
+ nextPage: () => setPage((p) => p + 1),
157
+ previousPage: () => setPage((p) => Math.max(1, p - 1)),
158
+ isLoading: directory.isLoading,
159
+ /** True while a new page or filter is loading behind the current results. */
160
+ isRefreshing: directory.isFetching && !directory.isLoading,
161
+ isError: directory.isError,
162
+ /** 403: the viewer is not a member of this community. */
163
+ forbidden: (directory.error as { response?: { status?: number } } | null)?.response?.status === 403,
164
+ // Map
165
+ view,
166
+ setView,
167
+ /** Globe dots: countries with a known centre. */
168
+ mapPoints: points,
169
+ /** Every country with members on the map, for the list. */
170
+ mapCountries: countries,
171
+ mapMembers: countries.reduce((n, c) => n + c.members, 0),
172
+ mapLoading: view === "map" && map.isLoading,
173
+ mapError: map.isError,
174
+ };
175
+ }