@tribe-nest/forge 3.60.0 → 3.63.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 (220) hide show
  1. package/package.json +5 -2
  2. package/src/contexts/CartContext.tsx +34 -1
  3. package/src/contexts/PublicAuthContext.tsx +15 -3
  4. package/src/data/queries/_tests/metaSignals.spec.tsx +130 -0
  5. package/src/data/queries/useAccount.ts +33 -2
  6. package/src/data/queries/useAnalytics.ts +6 -6
  7. package/src/data/queries/useCallChat.ts +2 -2
  8. package/src/data/queries/useChat.ts +10 -1
  9. package/src/data/queries/useCheckouts.ts +2 -1
  10. package/src/data/queries/useCoachingAvailability.ts +10 -1
  11. package/src/data/queries/useCommunities.ts +2 -0
  12. package/src/data/queries/useCommunity.ts +362 -18
  13. package/src/data/queries/useCommunityAgent.ts +247 -0
  14. package/src/data/queries/useCommunityCalendar.ts +150 -0
  15. package/src/data/queries/useCommunityClassroom.ts +75 -0
  16. package/src/data/queries/useCommunityMembers.ts +108 -0
  17. package/src/data/queries/useCommunityModeration.ts +327 -0
  18. package/src/data/queries/useCommunityWiki.ts +137 -0
  19. package/src/data/queries/useCourses.ts +2 -0
  20. package/src/data/queries/useEmailSignIn.ts +79 -0
  21. package/src/data/queries/useEvents.ts +6 -1
  22. package/src/data/queries/useForms.ts +5 -1
  23. package/src/data/queries/useLeadMagnet.ts +6 -2
  24. package/src/data/queries/useMyBookings.ts +24 -1
  25. package/src/data/queries/useMyTickets.ts +24 -1
  26. package/src/data/queries/useNotifications.ts +6 -2
  27. package/src/data/queries/useOrders.ts +2 -1
  28. package/src/data/queries/usePWA.ts +111 -35
  29. package/src/data/queries/useSiteApp.ts +130 -0
  30. package/src/data/queries/useSubscriptions.ts +6 -1
  31. package/src/i18n/de.json +746 -1
  32. package/src/i18n/en.json +746 -1
  33. package/src/index.ts +20 -0
  34. package/src/runtime/pages.spec.ts +5 -0
  35. package/src/runtime/pages.ts +32 -6
  36. package/src/seo/_tests/seo.spec.ts +326 -0
  37. package/src/seo/applySeoToHead.ts +98 -0
  38. package/src/seo/index.ts +9 -0
  39. package/src/seo/resolve.ts +211 -0
  40. package/src/seo/seoHead.ts +61 -0
  41. package/src/seo/siteSeoHead.ts +97 -0
  42. package/src/seo/types.ts +99 -0
  43. package/src/server/_tests/siteBootstrap.spec.ts +65 -0
  44. package/src/server/index.ts +33 -4
  45. package/src/types/models.ts +11 -0
  46. package/src/ui/analytics/ForgeAnalytics.tsx +134 -34
  47. package/src/ui/analytics/PageMetaPixel.tsx +23 -8
  48. package/src/ui/analytics/_tests/ForgeAnalytics.meta.spec.tsx +129 -0
  49. package/src/ui/analytics/_tests/ForgeAnalytics.webVitals.spec.tsx +129 -0
  50. package/src/ui/analytics/_tests/metaCommerceEvents.spec.tsx +159 -0
  51. package/src/ui/analytics/useMetaEvents.ts +78 -0
  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/communityHarness.tsx +10 -3
  56. package/src/ui/headless/_tests/communityMembers.spec.tsx +316 -0
  57. package/src/ui/headless/_tests/communityPoll.spec.tsx +389 -0
  58. package/src/ui/headless/_tests/memberHomeBar.spec.ts +84 -0
  59. package/src/ui/headless/_tests/siteAppBar.spec.ts +83 -0
  60. package/src/ui/headless/_tests/siteAppHarness.ts +19 -0
  61. package/src/ui/headless/_tests/siteAppHome.spec.ts +226 -0
  62. package/src/ui/headless/_tests/siteAppPrompts.spec.ts +185 -0
  63. package/src/ui/headless/_tests/useCommunityJoin.spec.tsx +228 -14
  64. package/src/ui/headless/_tests/useCommunityMembership.spec.tsx +29 -1
  65. package/src/ui/headless/_tests/useCommunityModeration.spec.tsx +420 -0
  66. package/src/ui/headless/_tests/useCommunityReads.spec.tsx +11 -2
  67. package/src/ui/headless/_tests/useCommunityWiki.spec.tsx +224 -0
  68. package/src/ui/headless/_tests/useSiteApp.spec.tsx +52 -0
  69. package/src/ui/headless/_tests/wikiMediaImage.spec.tsx +132 -0
  70. package/src/ui/headless/auth/useEmailCodeSignIn.ts +186 -0
  71. package/src/ui/headless/auth/useSignupForm.ts +20 -1
  72. package/src/ui/headless/chat/useChatAttachmentUpload.ts +9 -6
  73. package/src/ui/headless/chat/useChatRoom.ts +35 -1
  74. package/src/ui/headless/communities/index.ts +41 -0
  75. package/src/ui/headless/communities/useCommunityCalendar.ts +156 -0
  76. package/src/ui/headless/communities/useCommunityClassroom.ts +84 -0
  77. package/src/ui/headless/communities/useCommunityHome.ts +3 -0
  78. package/src/ui/headless/communities/useCommunityJoin.ts +73 -23
  79. package/src/ui/headless/communities/useCommunityMembership.ts +48 -2
  80. package/src/ui/headless/communities/useCommunityWiki.ts +266 -0
  81. package/src/ui/headless/community/communityPoll.ts +297 -0
  82. package/src/ui/headless/community/countries.ts +123 -0
  83. package/src/ui/headless/community/index.ts +81 -1
  84. package/src/ui/headless/community/linkPreview.ts +71 -0
  85. package/src/ui/headless/community/richLexical.ts +27 -2
  86. package/src/ui/headless/community/useCommunityAgent.ts +285 -0
  87. package/src/ui/headless/community/useCommunityMembers.ts +175 -0
  88. package/src/ui/headless/community/useCommunityModeration.ts +300 -0
  89. package/src/ui/headless/community/useCommunitySpaceFeed.ts +22 -4
  90. package/src/ui/headless/community/useCommunityThread.ts +16 -3
  91. package/src/ui/headless/community/useMentionComposer.ts +17 -9
  92. package/src/ui/headless/community/useRichMentionComposer.ts +21 -5
  93. package/src/ui/headless/downloads/index.ts +8 -0
  94. package/src/ui/headless/downloads/useDownloadPage.ts +134 -0
  95. package/src/ui/headless/index.ts +13 -0
  96. package/src/ui/headless/memberHome/index.ts +10 -0
  97. package/src/ui/headless/memberHome/memberHomeBar.ts +91 -0
  98. package/src/ui/headless/memberHome/useMemberHomeNav.ts +56 -18
  99. package/src/ui/headless/portal/index.ts +57 -0
  100. package/src/ui/headless/portal/usePortal.ts +274 -0
  101. package/src/ui/headless/portal/usePortalFiles.ts +68 -0
  102. package/src/ui/headless/portal/usePortalMessages.ts +189 -0
  103. package/src/ui/headless/portal/usePortalPreview.ts +142 -0
  104. package/src/ui/headless/siteApp/appBar.ts +102 -0
  105. package/src/ui/headless/siteApp/appHome.ts +344 -0
  106. package/src/ui/headless/siteApp/appPrompts.ts +229 -0
  107. package/src/ui/headless/siteApp/index.ts +58 -0
  108. package/src/ui/headless/siteApp/useAppHome.ts +210 -0
  109. package/src/ui/headless/siteApp/useAppInstallRequest.ts +16 -0
  110. package/src/ui/headless/work/index.ts +2 -0
  111. package/src/ui/headless/work/useWorkPortal.ts +37 -26
  112. package/src/ui/index.ts +11 -0
  113. package/src/ui/styled/AccountDashboard.tsx +712 -352
  114. package/src/ui/styled/AiAgentWidget.tsx +13 -1
  115. package/src/ui/styled/ChatRoom.tsx +126 -16
  116. package/src/ui/styled/Checkout.tsx +8 -0
  117. package/src/ui/styled/CheckoutConfirmation.tsx +8 -0
  118. package/src/ui/styled/CoachingConfirmation.tsx +8 -0
  119. package/src/ui/styled/CoachingDetail.tsx +2 -0
  120. package/src/ui/styled/CourseConfirmation.tsx +5 -0
  121. package/src/ui/styled/CourseDetail.tsx +2 -0
  122. package/src/ui/styled/DownloadPage.tsx +263 -0
  123. package/src/ui/styled/EventConfirmation.tsx +10 -0
  124. package/src/ui/styled/EventDetail.tsx +10 -0
  125. package/src/ui/styled/FilmLibrary.tsx +22 -9
  126. package/src/ui/styled/MusicLinkPage.tsx +5 -5
  127. package/src/ui/styled/ProductDetail.tsx +12 -0
  128. package/src/ui/styled/PwaRegistration.tsx +5 -1
  129. package/src/ui/styled/SignupForm.tsx +18 -3
  130. package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +5 -5
  131. package/src/ui/styled/_tests/AccountNotifications.spec.tsx +135 -0
  132. package/src/ui/styled/_tests/DownloadPage.spec.tsx +68 -0
  133. package/src/ui/styled/app/AppBar.tsx +298 -0
  134. package/src/ui/styled/app/AppHome.tsx +1139 -0
  135. package/src/ui/styled/app/AppHomeMark.tsx +66 -0
  136. package/src/ui/styled/app/AppPrompts.tsx +464 -0
  137. package/src/ui/styled/app/_tests/AppBar.spec.tsx +96 -0
  138. package/src/ui/styled/app/_tests/AppHome.spec.tsx +209 -0
  139. package/src/ui/styled/app/_tests/AppPrompts.spec.tsx +232 -0
  140. package/src/ui/styled/app/index.ts +7 -0
  141. package/src/ui/styled/chat/ChatThreadPanel.tsx +29 -4
  142. package/src/ui/styled/chat/MessageBubble.tsx +43 -6
  143. package/src/ui/styled/communities/CommunityAbout.tsx +6 -1
  144. package/src/ui/styled/communities/CommunityCalendar.tsx +484 -0
  145. package/src/ui/styled/communities/CommunityClassroom.tsx +302 -0
  146. package/src/ui/styled/communities/CommunityHome.tsx +134 -11
  147. package/src/ui/styled/communities/CommunityJoinSheet.tsx +119 -19
  148. package/src/ui/styled/communities/CommunityMembershipMenu.tsx +168 -52
  149. package/src/ui/styled/communities/CommunityPublicWiki.tsx +75 -0
  150. package/src/ui/styled/communities/CommunityWiki.tsx +289 -0
  151. package/src/ui/styled/communities/MyMembershipsList.tsx +31 -5
  152. package/src/ui/styled/communities/_tests/CommunityAppHome.spec.tsx +92 -0
  153. package/src/ui/styled/communities/_tests/CommunityClassroomCalendar.spec.tsx +453 -0
  154. package/src/ui/styled/communities/_tests/CommunityMobile.spec.tsx +399 -0
  155. package/src/ui/styled/communities/_tests/CommunityWiki.spec.tsx +223 -0
  156. package/src/ui/styled/communities/index.ts +9 -0
  157. package/src/ui/styled/communities/shared.tsx +39 -11
  158. package/src/ui/styled/community/CommunityAgent.tsx +286 -0
  159. package/src/ui/styled/community/CommunityComposer.tsx +266 -15
  160. package/src/ui/styled/community/CommunityFeed.tsx +50 -1
  161. package/src/ui/styled/community/CommunityLinkPreview.tsx +159 -0
  162. package/src/ui/styled/community/CommunityMemberGlobe.tsx +171 -0
  163. package/src/ui/styled/community/CommunityMemberProfile.tsx +137 -4
  164. package/src/ui/styled/community/CommunityMembers.tsx +387 -0
  165. package/src/ui/styled/community/CommunityModeration.tsx +587 -0
  166. package/src/ui/styled/community/CommunityModerationState.tsx +127 -0
  167. package/src/ui/styled/community/CommunityModeratorMenu.tsx +317 -0
  168. package/src/ui/styled/community/CommunityNotifications.tsx +52 -6
  169. package/src/ui/styled/community/CommunityPollCard.tsx +250 -0
  170. package/src/ui/styled/community/CommunityPostCard.tsx +110 -34
  171. package/src/ui/styled/community/CommunityPostDetail.tsx +157 -71
  172. package/src/ui/styled/community/CommunityRichText.tsx +177 -32
  173. package/src/ui/styled/community/CommunitySpaces.tsx +49 -14
  174. package/src/ui/styled/community/_tests/CommunityAgent.spec.tsx +571 -0
  175. package/src/ui/styled/community/_tests/CommunityLinkPreview.spec.tsx +167 -0
  176. package/src/ui/styled/community/_tests/CommunityMembers.spec.tsx +137 -0
  177. package/src/ui/styled/community/_tests/CommunityModeration.spec.tsx +291 -0
  178. package/src/ui/styled/community/_tests/CommunityPoll.spec.tsx +386 -0
  179. package/src/ui/styled/community/index.ts +37 -2
  180. package/src/ui/styled/forge-utilities.css +234 -0
  181. package/src/ui/styled/members/CallRecordingActions.tsx +182 -0
  182. package/src/ui/styled/members/MemberCallsCalendar.tsx +13 -158
  183. package/src/ui/styled/members/MemberHomeLayout.tsx +295 -15
  184. package/src/ui/styled/members/MemberPostsFeed.tsx +3 -2
  185. package/src/ui/styled/members/index.ts +8 -0
  186. package/src/ui/styled/portal/PortalAccountSection.tsx +61 -0
  187. package/src/ui/styled/portal/PortalActivity.tsx +167 -0
  188. package/src/ui/styled/portal/PortalBilling.tsx +197 -0
  189. package/src/ui/styled/portal/PortalFiles.tsx +279 -0
  190. package/src/ui/styled/portal/PortalHome.tsx +401 -0
  191. package/src/ui/styled/portal/PortalMessages.tsx +300 -0
  192. package/src/ui/styled/portal/PortalPreviewBanner.tsx +75 -0
  193. package/src/ui/styled/portal/PortalProjects.tsx +407 -0
  194. package/src/ui/styled/portal/PortalShell.tsx +397 -0
  195. package/src/ui/styled/portal/_tests/PortalAppHome.spec.tsx +90 -0
  196. package/src/ui/styled/portal/_tests/PortalFiles.spec.tsx +159 -0
  197. package/src/ui/styled/portal/_tests/PortalHome.spec.tsx +148 -0
  198. package/src/ui/styled/portal/_tests/PortalMessages.spec.tsx +168 -0
  199. package/src/ui/styled/portal/_tests/PortalPreviewBanner.spec.tsx +132 -0
  200. package/src/ui/styled/portal/_tests/portalNav.spec.ts +123 -0
  201. package/src/ui/styled/portal/index.ts +13 -0
  202. package/src/ui/styled/portal/portalNav.ts +260 -0
  203. package/src/ui/styled/portal/shared.tsx +206 -0
  204. package/src/ui/styled/work/WorkInviteAccept.tsx +228 -168
  205. package/src/ui/styled/work/WorkPortalProjects.tsx +68 -120
  206. package/src/ui/styled/work/WorkProjectDocuments.tsx +99 -128
  207. package/src/ui/styled/work/WorkProjectInvoices.tsx +91 -86
  208. package/src/ui/styled/work/WorkProjectReport.tsx +11 -17
  209. package/src/ui/styled/work/WorkReportBody.tsx +223 -248
  210. package/src/ui/styled/work/WorkTaskAttachments.tsx +23 -29
  211. package/src/ui/styled/work/WorkTaskComments.tsx +75 -102
  212. package/src/ui/styled/work/WorkTaskDetail.tsx +48 -59
  213. package/src/ui/styled/work/WorkTokenReport.tsx +8 -16
  214. package/src/ui/styled/work/index.ts +1 -1
  215. package/src/utils/_tests/landing.spec.ts +39 -0
  216. package/src/utils/_tests/metaEvents.spec.ts +264 -0
  217. package/src/utils/_tests/structuredDataSeo.spec.ts +247 -0
  218. package/src/utils/landing.ts +36 -10
  219. package/src/utils/metaEvents.ts +302 -0
  220. package/src/utils/structuredData.ts +339 -1
@@ -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
+ });