@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,519 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
+ import { act, renderHook, waitFor } from "@testing-library/react";
4
+ import { QueryClient } from "@tanstack/react-query";
5
+ import type { Socket } from "socket.io-client";
6
+
7
+ import {
8
+ COMMUNITY,
9
+ PROFILE_ID,
10
+ account,
11
+ calls,
12
+ get,
13
+ installAdapter,
14
+ meRoute,
15
+ post,
16
+ restoreAdapter,
17
+ viewer,
18
+ wrapper,
19
+ type Route,
20
+ } from "./communityHarness";
21
+ import {
22
+ isCommunityAgentDm,
23
+ useCommunityAgent,
24
+ useCommunityAgentRatings,
25
+ useOpenCommunityAgentDm,
26
+ } from "../../../data/queries/useCommunityAgent";
27
+ import {
28
+ applyCommunityContentHeld,
29
+ communityKeys,
30
+ type CommunityPost,
31
+ type CommunityReply,
32
+ } from "../../../data/queries/useCommunity";
33
+ import {
34
+ AGENT_THINKING_TIMEOUT_MS,
35
+ useCommunityAgentAnswerRatings,
36
+ linkifyText,
37
+ useChatAgentThinking,
38
+ useCommunityAgentAnswerFeedback,
39
+ useCommunityAgentThinking,
40
+ useCommunityCatchUp,
41
+ } from "../community/useCommunityAgent";
42
+ import { useRichMentionComposer } from "../community/useRichMentionComposer";
43
+ import { serializeRichToLexical, isRichEmpty } from "../community/richLexical";
44
+
45
+ /**
46
+ * The member side of the community AI (community parity §8.1, §8.2, §8.5),
47
+ * driven through the real data hooks over a stub adapter so each test asserts
48
+ * what is SENT, plus the pure helpers and the socket-driven thinking state.
49
+ */
50
+
51
+ const AGENT = { enabled: true, id: "agent-1", name: "Assistant", avatarUrl: null, dmEnabled: true };
52
+ const ME = account();
53
+
54
+ const agentRoute = (info: unknown = AGENT): Route => get("/public/community-agent", () => info);
55
+ const memberRoutes = (info: unknown = AGENT): Route[] => [meRoute(() => viewer({ state: "member" })), agentRoute(info)];
56
+
57
+ afterEach(() => restoreAdapter());
58
+
59
+ // ── A fake socket: the hooks only ever call on/off/emit. ─────────────────────
60
+ type Listener = (envelope: unknown) => void;
61
+ function fakeSocket() {
62
+ const listeners = new Set<Listener>();
63
+ const socket = {
64
+ connected: true,
65
+ on: (event: string, fn: Listener) => event === "message" && listeners.add(fn),
66
+ off: (event: string, fn: Listener) => event === "message" && listeners.delete(fn),
67
+ emit: vi.fn(),
68
+ };
69
+ const deliver = (event: string, data: unknown) => act(() => listeners.forEach((fn) => fn({ event, data })));
70
+ return { socket: socket as unknown as Socket, deliver };
71
+ }
72
+
73
+ describe("useCommunityAgent: the agent info query", () => {
74
+ it("asks for the agent with the profile and community once the viewer is a signed-in member", async () => {
75
+ installAdapter(memberRoutes());
76
+ const { result } = renderHook(() => useCommunityAgent(), { wrapper: wrapper({ user: ME }) });
77
+ await waitFor(() => expect(result.current.agent).not.toBeNull());
78
+ expect(result.current.agent).toEqual(AGENT);
79
+ expect(calls("get", "/public/community-agent")[0].params).toEqual({ profileId: PROFILE_ID, communityId: COMMUNITY.id });
80
+ });
81
+
82
+ it("asks for nothing when signed out", async () => {
83
+ installAdapter(memberRoutes());
84
+ const { result } = renderHook(() => useCommunityAgent(), { wrapper: wrapper() });
85
+ await new Promise((r) => setTimeout(r, 20));
86
+ expect(result.current.agent).toBeNull();
87
+ expect(calls("get", "/public/community-agent")).toHaveLength(0);
88
+ });
89
+
90
+ it("asks for nothing for a signed-in viewer who is not a member", async () => {
91
+ installAdapter([meRoute(() => viewer({ state: "none" })), agentRoute()]);
92
+ const { result } = renderHook(() => useCommunityAgent(), { wrapper: wrapper({ user: ME }) });
93
+ await waitFor(() => expect(calls("get", /\/me$/)).toHaveLength(1));
94
+ await new Promise((r) => setTimeout(r, 20));
95
+ expect(result.current.agent).toBeNull();
96
+ expect(calls("get", "/public/community-agent")).toHaveLength(0);
97
+ });
98
+
99
+ it("reads a switched-off agent as no agent", async () => {
100
+ installAdapter(memberRoutes({ enabled: false }));
101
+ const { result } = renderHook(() => useCommunityAgent(), { wrapper: wrapper({ user: ME }) });
102
+ await waitFor(() => expect(result.current.data).toEqual({ enabled: false }));
103
+ expect(result.current.agent).toBeNull();
104
+ });
105
+ });
106
+
107
+ describe("useOpenCommunityAgentDm", () => {
108
+ it("posts the profile and community and answers the channel", async () => {
109
+ installAdapter([post("/public/community-agent/dm", () => ({ channelId: "chan-ai", agent: AGENT }))]);
110
+ const { result } = renderHook(() => useOpenCommunityAgentDm(), { wrapper: wrapper({ user: ME }) });
111
+ let answer: unknown;
112
+ await act(async () => {
113
+ answer = await result.current.mutateAsync();
114
+ });
115
+ expect(answer).toEqual({ channelId: "chan-ai", agent: AGENT });
116
+ expect(calls("post", "/public/community-agent/dm")[0].body).toEqual({ profileId: PROFILE_ID, communityId: COMMUNITY.id });
117
+ });
118
+
119
+ it("recognises the agent DM by its key, and nothing else", () => {
120
+ expect(isCommunityAgentDm({ type: "dm", dmKey: "agent:agent-1:account-1" })).toBe(true);
121
+ expect(isCommunityAgentDm({ type: "dm", dmKey: null })).toBe(false);
122
+ expect(isCommunityAgentDm({ type: "dm", dmKey: "a:b" })).toBe(false);
123
+ expect(isCommunityAgentDm({ type: "public", dmKey: "agent:x" })).toBe(false);
124
+ });
125
+ });
126
+
127
+ describe("the mention picker lists the agent first and inserts an agent mention", () => {
128
+ const MEMBERS = [
129
+ { accountId: "acct-ada", displayName: "Ada", firstName: "Ada", lastName: null, avatarUrl: null, level: 2, points: 5 },
130
+ { accountId: "acct-asa", displayName: "Asa", firstName: "Asa", lastName: null, avatarUrl: null, level: 1, points: 1 },
131
+ ];
132
+
133
+ function mountComposer() {
134
+ installAdapter([...memberRoutes(), get("/public/community/members", () => ({ data: MEMBERS }))]);
135
+ const editor = document.createElement("div");
136
+ document.body.appendChild(editor);
137
+ const hook = renderHook(
138
+ () => {
139
+ const composer = useRichMentionComposer();
140
+ composer.editorRef.current = editor;
141
+ return composer;
142
+ },
143
+ { wrapper: wrapper({ user: ME }) },
144
+ );
145
+ return { editor, hook };
146
+ }
147
+
148
+ /** Type `text` into the editor with the caret at its end, as the member does. */
149
+ function typeAt(editor: HTMLDivElement, text: string, onChange: () => void) {
150
+ editor.textContent = text;
151
+ const node = editor.firstChild as Text;
152
+ const range = document.createRange();
153
+ range.setStart(node, text.length);
154
+ range.collapse(true);
155
+ const sel = window.getSelection()!;
156
+ sel.removeAllRanges();
157
+ sel.addRange(range);
158
+ act(() => onChange());
159
+ }
160
+
161
+ it("puts the agent above every member, with its id and no account", async () => {
162
+ const { editor, hook } = mountComposer();
163
+ await waitFor(() => expect(hook.result.current.agent).not.toBeNull());
164
+ typeAt(editor, "Hi @A", hook.result.current.onEditorChange);
165
+ await waitFor(() => expect(hook.result.current.suggestions.length).toBe(3));
166
+ const [first, ...rest] = hook.result.current.suggestions;
167
+ expect(first).toMatchObject({ agentId: "agent-1", name: "Assistant", accountId: "" });
168
+ expect(rest.map((s) => s.accountId)).toEqual(["acct-ada", "acct-asa"]);
169
+ editor.remove();
170
+ });
171
+
172
+ it("leaves the agent out when the typed name does not match it", async () => {
173
+ const { editor, hook } = mountComposer();
174
+ await waitFor(() => expect(hook.result.current.agent).not.toBeNull());
175
+ typeAt(editor, "@Ad", hook.result.current.onEditorChange);
176
+ await waitFor(() => expect(hook.result.current.suggestions.length).toBeGreaterThan(0));
177
+ expect(hook.result.current.suggestions.some((s) => s.agentId)).toBe(false);
178
+ editor.remove();
179
+ });
180
+
181
+ it("inserts { type: mention, agentId, mentionName } and never an accountId", async () => {
182
+ const { editor, hook } = mountComposer();
183
+ await waitFor(() => expect(hook.result.current.agent).not.toBeNull());
184
+ typeAt(editor, "Hey @Ass", hook.result.current.onEditorChange);
185
+ await waitFor(() => expect(hook.result.current.suggestions[0]?.agentId).toBe("agent-1"));
186
+ act(() => hook.result.current.insertMention(hook.result.current.suggestions[0]));
187
+
188
+ const body = hook.result.current.getBody() as { root: { children: { children: Record<string, unknown>[] }[] } };
189
+ const mention = body.root.children[0].children.find((n) => n.type === "mention")!;
190
+ expect(mention).toMatchObject({ type: "mention", agentId: "agent-1", mentionName: "Assistant", text: "@Assistant" });
191
+ expect(mention).not.toHaveProperty("accountId");
192
+ expect(mention).not.toHaveProperty("id");
193
+ expect(mention).not.toHaveProperty("mentionId");
194
+ // The agent is not a member: it is never in the mentioned accounts.
195
+ expect(hook.result.current.getMentionedAccountIds()).toEqual([]);
196
+ editor.remove();
197
+ });
198
+
199
+ it("serializes an agent chip on its own, and counts it as content", () => {
200
+ const root = document.createElement("div");
201
+ root.innerHTML = '<p><span data-agent-id="agent-1" data-mention-name="Assistant" contenteditable="false">@Assistant</span></p>';
202
+ expect(isRichEmpty(root)).toBe(false);
203
+ const state = serializeRichToLexical(root) as { root: { children: { children: unknown[] }[] } };
204
+ expect(state.root.children[0].children[0]).toMatchObject({ type: "mention", agentId: "agent-1", mentionName: "Assistant" });
205
+ });
206
+ });
207
+
208
+ describe("thinking indicators", () => {
209
+ beforeEach(() => vi.useRealTimers());
210
+
211
+ it("community: on with thinking, off with done", () => {
212
+ const { socket, deliver } = fakeSocket();
213
+ const { result } = renderHook(() => useCommunityAgentThinking(socket));
214
+ expect(result.current.isThinking("post-1")).toBe(false);
215
+ deliver("communityAgentThinking", { postId: "post-1", agentId: "agent-1", state: "thinking" });
216
+ expect(result.current.isThinking("post-1")).toBe(true);
217
+ expect(result.current.isThinking("post-2")).toBe(false);
218
+ deliver("communityAgentThinking", { postId: "post-1", agentId: "agent-1", state: "done" });
219
+ expect(result.current.isThinking("post-1")).toBe(false);
220
+ });
221
+
222
+ it("community: off when the answer arrives, even before done", () => {
223
+ const { socket, deliver } = fakeSocket();
224
+ const { result } = renderHook(() => useCommunityAgentThinking(socket));
225
+ deliver("communityAgentThinking", { postId: "post-1", agentId: "agent-1", state: "thinking" });
226
+ // A member's reply does not clear it.
227
+ deliver("communityNewReply", { postId: "post-1", reply: { id: "r1", agentAuthor: null } });
228
+ expect(result.current.isThinking("post-1")).toBe(true);
229
+ deliver("communityNewReply", { postId: "post-1", reply: { id: "r2", agentAuthor: { id: "agent-1", name: "A", avatarUrl: null } } });
230
+ expect(result.current.isThinking("post-1")).toBe(false);
231
+ });
232
+
233
+ it("community: only the posts the caller accepts", () => {
234
+ const { socket, deliver } = fakeSocket();
235
+ const { result } = renderHook(() => useCommunityAgentThinking(socket, (id) => id === "post-1"));
236
+ deliver("communityAgentThinking", { postId: "post-9", agentId: "agent-1", state: "thinking" });
237
+ expect(result.current.isThinking("post-9")).toBe(false);
238
+ });
239
+
240
+ it("community: clears itself if done never arrives", () => {
241
+ vi.useFakeTimers();
242
+ const { socket, deliver } = fakeSocket();
243
+ const { result } = renderHook(() => useCommunityAgentThinking(socket));
244
+ deliver("communityAgentThinking", { postId: "post-1", agentId: "agent-1", state: "thinking" });
245
+ expect(result.current.isThinking("post-1")).toBe(true);
246
+ act(() => vi.advanceTimersByTime(AGENT_THINKING_TIMEOUT_MS + 1));
247
+ expect(result.current.isThinking("post-1")).toBe(false);
248
+ vi.useRealTimers();
249
+ });
250
+
251
+ it("chat: keyed by channel and thread, cleared by done or the agent's message", () => {
252
+ const { socket, deliver } = fakeSocket();
253
+ const { result } = renderHook(() => useChatAgentThinking(socket));
254
+ deliver("chatAgentThinking", { channelId: "c1", parentMessageId: "m1", agentId: "agent-1", state: "thinking" });
255
+ expect(result.current.isThinking("c1", "m1")).toBe(true);
256
+ expect(result.current.isThinking("c1", null)).toBe(false);
257
+ deliver("chatAgentThinking", { channelId: "c1", parentMessageId: "m1", agentId: "agent-1", state: "done" });
258
+ expect(result.current.isThinking("c1", "m1")).toBe(false);
259
+
260
+ // The DM: no thread. A member's message does not clear it; the agent's does.
261
+ deliver("chatAgentThinking", { channelId: "dm", parentMessageId: null, agentId: "agent-1", state: "thinking" });
262
+ expect(result.current.isThinking("dm", null)).toBe(true);
263
+ deliver("chatNewMessage", { channelId: "dm", message: { id: "x", parentMessageId: null, senderAgentId: null } });
264
+ expect(result.current.isThinking("dm", null)).toBe(true);
265
+ deliver("chatNewMessage", { channelId: "dm", message: { id: "y", parentMessageId: null, senderAgentId: "agent-1" } });
266
+ expect(result.current.isThinking("dm", null)).toBe(false);
267
+ });
268
+ });
269
+
270
+ describe("thumbs up / down", () => {
271
+ it("sends the entity and value, optimistically, and a second press clears it", async () => {
272
+ installAdapter([post("/public/community-agent/feedback", (req) => ({ id: "ans-1", feedback: req.body?.value || null }))]);
273
+ const { result } = renderHook(() => useCommunityAgentAnswerFeedback("community_reply", "reply-ai"), {
274
+ wrapper: wrapper({ user: ME }),
275
+ });
276
+ act(() => result.current.rate(1));
277
+ expect(result.current.value).toBe(1);
278
+ await waitFor(() => expect(calls("post", "/public/community-agent/feedback")).toHaveLength(1));
279
+ expect(calls("post", "/public/community-agent/feedback")[0].body).toEqual({
280
+ profileId: PROFILE_ID,
281
+ entityType: "community_reply",
282
+ entityId: "reply-ai",
283
+ value: 1,
284
+ });
285
+ act(() => result.current.rate(1));
286
+ expect(result.current.value).toBeNull();
287
+ await waitFor(() => expect(calls("post", "/public/community-agent/feedback")).toHaveLength(2));
288
+ expect(calls("post", "/public/community-agent/feedback")[1].body?.value).toBe(0);
289
+ });
290
+
291
+ it("rolls back and hides itself on a 404 (not the member who asked)", async () => {
292
+ installAdapter([
293
+ post("/public/community-agent/feedback", () => {
294
+ throw { status: 404 };
295
+ }),
296
+ ]);
297
+ const { result } = renderHook(() => useCommunityAgentAnswerFeedback("chat_message", "msg-ai"), {
298
+ wrapper: wrapper({ user: ME }),
299
+ });
300
+ act(() => result.current.rate(-1));
301
+ expect(result.current.value).toBe(-1);
302
+ await waitFor(() => expect(result.current.hidden).toBe(true));
303
+ expect(result.current.value).toBeNull();
304
+ });
305
+
306
+ it("starts from the saved rating, so a reload keeps it", async () => {
307
+ installAdapter([post("/public/community-agent/feedback", () => ({ id: "ans", feedback: null }))]);
308
+ const { result } = renderHook(() => useCommunityAgentAnswerFeedback("community_reply", "reply-ai", -1), {
309
+ wrapper: wrapper({ user: ME }),
310
+ });
311
+ expect(result.current.value).toBe(-1);
312
+ // Pressing the saved thumb clears it.
313
+ act(() => result.current.rate(-1));
314
+ expect(result.current.value).toBeNull();
315
+ await waitFor(() => expect(calls("post", "/public/community-agent/feedback")).toHaveLength(1));
316
+ expect(calls("post", "/public/community-agent/feedback")[0].body?.value).toBe(0);
317
+ });
318
+ });
319
+
320
+ describe("the member's own ratings: hydration and who sees thumbs", () => {
321
+ const ratingsPath = "/public/community-agent/feedback";
322
+ const ratingsRoute = (rows: { entityId: string; feedback: 1 | -1 | null }[]): Route =>
323
+ get(ratingsPath, (req) => {
324
+ const asked = String(req.params?.entityIds ?? "").split(",");
325
+ return { data: rows.filter((r) => asked.includes(r.entityId)) };
326
+ });
327
+
328
+ it("reads once per list with the sorted ids, and only returned answers are ratable", async () => {
329
+ installAdapter([...memberRoutes(), ratingsRoute([{ entityId: "a-2", feedback: 1 }])]);
330
+ const { result } = renderHook(() => useCommunityAgentAnswerRatings("community_reply", ["a-3", "a-2", "a-1"]), {
331
+ wrapper: wrapper({ user: ME }),
332
+ });
333
+ await waitFor(() => expect(result.current.canRate("a-2")).toBe(true));
334
+ expect(result.current.ratingOf("a-2")).toBe(1);
335
+ // Someone else's answers are absent from the read, so no thumbs.
336
+ expect(result.current.canRate("a-1")).toBe(false);
337
+ expect(result.current.canRate("a-3")).toBe(false);
338
+ const reads = calls("get", ratingsPath);
339
+ expect(reads).toHaveLength(1);
340
+ expect(reads[0].params).toEqual({ profileId: PROFILE_ID, entityType: "community_reply", entityIds: "a-1,a-2,a-3" });
341
+ });
342
+
343
+ it("keys by the sorted ids: the same answers in another order cost no second read", async () => {
344
+ installAdapter([...memberRoutes(), ratingsRoute([])]);
345
+ const { result } = renderHook(
346
+ () => ({
347
+ one: useCommunityAgentRatings("chat_message", ["m-2", "m-1"]),
348
+ two: useCommunityAgentRatings("chat_message", ["m-1", "m-2", "m-1"]),
349
+ }),
350
+ { wrapper: wrapper({ user: ME }) },
351
+ );
352
+ await waitFor(() => expect(result.current.one.isSuccess && result.current.two.isSuccess).toBe(true));
353
+ expect(calls("get", ratingsPath)).toHaveLength(1);
354
+ });
355
+
356
+ it("splits more than 100 answers into batches of 100", async () => {
357
+ installAdapter([...memberRoutes(), ratingsRoute([{ entityId: "id-150", feedback: -1 }])]);
358
+ const ids = Array.from({ length: 150 }, (_, i) => `id-${i}`);
359
+ const { result } = renderHook(() => useCommunityAgentAnswerRatings("chat_message", ids), { wrapper: wrapper({ user: ME }) });
360
+ await waitFor(() => expect(calls("get", ratingsPath)).toHaveLength(2));
361
+ expect(calls("get", ratingsPath).map((r) => String(r.params?.entityIds).split(",").length)).toEqual([100, 50]);
362
+ // id-150 was not in the list, so nothing is ratable here.
363
+ await waitFor(() => expect(result.current.canRate("id-1")).toBe(false));
364
+ });
365
+
366
+ it("asks nothing when signed out", async () => {
367
+ installAdapter([...memberRoutes(), ratingsRoute([])]);
368
+ renderHook(() => useCommunityAgentRatings("community_reply", ["a-1"]), { wrapper: wrapper() });
369
+ await new Promise((r) => setTimeout(r, 30));
370
+ expect(calls("get", ratingsPath)).toHaveLength(0);
371
+ });
372
+
373
+ it("asks nothing when the AI is off", async () => {
374
+ installAdapter([...memberRoutes({ enabled: false }), ratingsRoute([])]);
375
+ renderHook(() => useCommunityAgentRatings("community_reply", ["a-1"]), { wrapper: wrapper({ user: ME }) });
376
+ await waitFor(() => expect(calls("get", "/public/community-agent")).toHaveLength(1));
377
+ await new Promise((r) => setTimeout(r, 30));
378
+ expect(calls("get", ratingsPath)).toHaveLength(0);
379
+ });
380
+
381
+ it("asks nothing with no answers in view", async () => {
382
+ installAdapter([...memberRoutes(), ratingsRoute([])]);
383
+ renderHook(() => useCommunityAgentRatings("community_reply", []), { wrapper: wrapper({ user: ME }) });
384
+ await waitFor(() => expect(calls("get", "/public/community-agent")).toHaveLength(1));
385
+ await new Promise((r) => setTimeout(r, 30));
386
+ expect(calls("get", ratingsPath)).toHaveLength(0);
387
+ });
388
+
389
+ it("a saved rating updates the cached read, and an unasked answer is never added", async () => {
390
+ installAdapter([
391
+ ...memberRoutes(),
392
+ ratingsRoute([{ entityId: "a-1", feedback: null }]),
393
+ post(ratingsPath, (req) => ({ id: "ans", feedback: req.body?.value })),
394
+ ]);
395
+ const { result } = renderHook(
396
+ () => ({
397
+ ratings: useCommunityAgentAnswerRatings("community_reply", ["a-1", "a-9"]),
398
+ feedback: useCommunityAgentAnswerFeedback("community_reply", "a-1"),
399
+ }),
400
+ { wrapper: wrapper({ user: ME }) },
401
+ );
402
+ await waitFor(() => expect(result.current.ratings.canRate("a-1")).toBe(true));
403
+ act(() => result.current.feedback.rate(1));
404
+ await waitFor(() => expect(result.current.ratings.ratingOf("a-1")).toBe(1));
405
+ expect(result.current.ratings.canRate("a-9")).toBe(false);
406
+ expect(calls("get", ratingsPath)).toHaveLength(1);
407
+ });
408
+ });
409
+
410
+ describe("catch me up", () => {
411
+ const summaryPath = "/public/community-agent/posts/post-1/summary";
412
+
413
+ it("posts the profile and community and keeps the summary until dismissed", async () => {
414
+ installAdapter([post(summaryPath, () => ({ summary: "Three people agreed on Berlin.", agentName: "Assistant" }))]);
415
+ const { result } = renderHook(() => useCommunityCatchUp("post-1"), { wrapper: wrapper({ user: ME }) });
416
+ act(() => result.current.ask());
417
+ await waitFor(() => expect(result.current.summary).not.toBeNull());
418
+ expect(result.current.summary).toEqual({ summary: "Three people agreed on Berlin.", agentName: "Assistant" });
419
+ expect(calls("post", summaryPath)[0].body).toEqual({ profileId: PROFILE_ID, communityId: COMMUNITY.id });
420
+ act(() => result.current.dismiss());
421
+ expect(result.current.summary).toBeNull();
422
+ });
423
+
424
+ it.each([
425
+ [409, "daily_limit"],
426
+ [400, "unavailable"],
427
+ [500, "failed"],
428
+ ])("maps a %s to %s", async (status, error) => {
429
+ installAdapter([
430
+ post(summaryPath, () => {
431
+ throw { status };
432
+ }),
433
+ ]);
434
+ const { result } = renderHook(() => useCommunityCatchUp("post-1"), { wrapper: wrapper({ user: ME }) });
435
+ act(() => result.current.ask());
436
+ await waitFor(() => expect(result.current.error).toBe(error));
437
+ expect(result.current.summary).toBeNull();
438
+ });
439
+ });
440
+
441
+ describe("linkifyText", () => {
442
+ it("makes the Sources addresses links and keeps everything else as text", () => {
443
+ const text = "Yes.\n\nSources:\n- Refunds: https://fans.example.com/c/fan-club/wiki/refunds.\n- Tour (no link)";
444
+ const segments = linkifyText(text);
445
+ const links = segments.filter((s) => s.kind === "link");
446
+ expect(links).toEqual([
447
+ { kind: "link", text: "https://fans.example.com/c/fan-club/wiki/refunds", url: "https://fans.example.com/c/fan-club/wiki/refunds" },
448
+ ]);
449
+ expect(segments.map((s) => s.text).join("")).toBe(text);
450
+ });
451
+
452
+ it("never links a non-http scheme", () => {
453
+ expect(linkifyText("javascript:alert(1) and ftp://x").every((s) => s.kind === "text")).toBe(true);
454
+ });
455
+
456
+ it("shows a titled source as its title, linking to its address", () => {
457
+ const text = "Yes.\n\nSources:\n- [How it works](https://fans.example.com/c/fan-club/wiki/how-it-works)\n- [FAQ](https://fans.example.com/c/fan-club/wiki/faq)\n- Tour";
458
+ expect(linkifyText(text)).toEqual([
459
+ { kind: "text", text: "Yes.\n\nSources:\n- " },
460
+ { kind: "link", text: "How it works", url: "https://fans.example.com/c/fan-club/wiki/how-it-works" },
461
+ { kind: "text", text: "\n- " },
462
+ { kind: "link", text: "FAQ", url: "https://fans.example.com/c/fan-club/wiki/faq" },
463
+ { kind: "text", text: "\n- Tour" },
464
+ ]);
465
+ });
466
+
467
+ it("never turns a titled link with a non-http address into a link", () => {
468
+ const segments = linkifyText("[Click me](javascript:alert(1)) and [Data](data:text/html,x)");
469
+ expect(segments.every((s) => s.kind === "text")).toBe(true);
470
+ expect(segments.map((s) => s.text).join("")).toBe("[Click me](javascript:alert(1)) and [Data](data:text/html,x)");
471
+ });
472
+ });
473
+
474
+ describe("communityContentHeld: held content leaves open feeds and threads", () => {
475
+ const SPACE = "space-1";
476
+ const feedPost = (id: string, authorAccountId: string): CommunityPost =>
477
+ ({ id, spaceId: SPACE, authorAccountId, replyCount: 2, status: "published" }) as CommunityPost;
478
+ const reply = (id: string, authorAccountId: string): CommunityReply =>
479
+ ({ id, postId: "p1", authorAccountId, parentReplyId: null, status: "published" }) as CommunityReply;
480
+ const page = <T,>(data: T[]) => ({ pages: [{ data, total: data.length, page: 1, pageSize: 20, hasNextPage: false }], pageParams: [1] });
481
+
482
+ it("drops another member's held post from the feed and the open post", () => {
483
+ const qc = new QueryClient();
484
+ qc.setQueryData(communityKeys.posts(SPACE), page([feedPost("p1", "someone"), feedPost("p2", "someone")]));
485
+ qc.setQueryData(communityKeys.post("p1"), feedPost("p1", "someone"));
486
+ applyCommunityContentHeld(qc, { spaceId: SPACE, entityType: "community_post", postId: "p1", replyId: null }, ME.id);
487
+ const feed = qc.getQueryData<any>(communityKeys.posts(SPACE));
488
+ expect(feed.pages[0].data.map((p: CommunityPost) => p.id)).toEqual(["p2"]);
489
+ expect(feed.pages[0].total).toBe(1);
490
+ expect(qc.getQueryData(communityKeys.post("p1"))).toBeNull();
491
+ });
492
+
493
+ it("keeps its author's own held post, marked as waiting", () => {
494
+ const qc = new QueryClient();
495
+ qc.setQueryData(communityKeys.posts(SPACE), page([feedPost("p1", ME.id)]));
496
+ applyCommunityContentHeld(qc, { spaceId: SPACE, entityType: "community_post", postId: "p1", replyId: null }, ME.id);
497
+ const feed = qc.getQueryData<any>(communityKeys.posts(SPACE));
498
+ expect(feed.pages[0].data).toEqual([expect.objectContaining({ id: "p1", status: "pending" })]);
499
+ });
500
+
501
+ it("drops a held reply from the thread and lowers the reply counts", () => {
502
+ const qc = new QueryClient();
503
+ qc.setQueryData(communityKeys.replies("p1"), page([reply("r1", "someone"), reply("r2", "someone")]));
504
+ qc.setQueryData(communityKeys.post("p1"), feedPost("p1", "someone"));
505
+ qc.setQueryData(communityKeys.posts(SPACE), page([feedPost("p1", "someone")]));
506
+ applyCommunityContentHeld(qc, { spaceId: SPACE, entityType: "community_reply", postId: "p1", replyId: "r1" }, ME.id);
507
+ const replies = qc.getQueryData<any>(communityKeys.replies("p1"));
508
+ expect(replies.pages[0].data.map((r: CommunityReply) => r.id)).toEqual(["r2"]);
509
+ expect(qc.getQueryData<CommunityPost>(communityKeys.post("p1"))?.replyCount).toBe(1);
510
+ expect(qc.getQueryData<any>(communityKeys.posts(SPACE)).pages[0].data[0].replyCount).toBe(1);
511
+ });
512
+
513
+ it("is a no-op for a reply that is not loaded", () => {
514
+ const qc = new QueryClient();
515
+ qc.setQueryData(communityKeys.post("p1"), feedPost("p1", "someone"));
516
+ applyCommunityContentHeld(qc, { spaceId: SPACE, entityType: "community_reply", postId: "p1", replyId: "nope" }, ME.id);
517
+ expect(qc.getQueryData<CommunityPost>(communityKeys.post("p1"))?.replyCount).toBe(2);
518
+ });
519
+ });