@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,571 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
+ import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
4
+ import type { ReactNode } from "react";
5
+
6
+ import {
7
+ COMMUNITY,
8
+ PROFILE_ID,
9
+ account,
10
+ calls,
11
+ get,
12
+ installAdapter,
13
+ meRoute,
14
+ post,
15
+ restoreAdapter,
16
+ viewer,
17
+ wrapper,
18
+ type Route,
19
+ } from "../../../headless/_tests/communityHarness";
20
+ import type { CommunityPost, CommunityReply } from "../../../../data/queries/useCommunity";
21
+ import type { ChatMessage } from "../../../../data/queries/useChat";
22
+ import { CommunityPostCard } from "../CommunityPostCard";
23
+ import { CommunityPostDetail } from "../CommunityPostDetail";
24
+ import { ChatRoom } from "../../ChatRoom";
25
+ import { MessageBubble } from "../../chat/MessageBubble";
26
+ import { ChatThreadPanel } from "../../chat/ChatThreadPanel";
27
+
28
+ /**
29
+ * The community AI as a member sees it, rendered for real over the harness's
30
+ * stub adapter, with a fake socket standing in for the realtime gateway so the
31
+ * thinking and held-content events can be delivered by hand.
32
+ */
33
+
34
+ const socketState = vi.hoisted(() => {
35
+ const listeners = new Set<(envelope: unknown) => void>();
36
+ return {
37
+ listeners,
38
+ socket: {
39
+ connected: true,
40
+ on: (event: string, fn: (envelope: unknown) => void) => {
41
+ if (event === "message") listeners.add(fn);
42
+ },
43
+ off: (event: string, fn: (envelope: unknown) => void) => {
44
+ if (event === "message") listeners.delete(fn);
45
+ },
46
+ emit: () => {},
47
+ },
48
+ };
49
+ });
50
+
51
+ vi.mock("socket.io-client", () => ({ io: () => socketState.socket }));
52
+
53
+ const deliver = (event: string, data: unknown) =>
54
+ act(() => {
55
+ socketState.listeners.forEach((fn) => fn({ event, data }));
56
+ });
57
+
58
+ const ME = account();
59
+ const AGENT = { enabled: true, id: "agent-1", name: "Assistant", avatarUrl: null, dmEnabled: true };
60
+ const AGENT_AUTHOR = { id: "agent-1", name: "Assistant", avatarUrl: null };
61
+ const SOURCE_URL = "https://fans.example.com/c/fan-club/wiki/refunds";
62
+
63
+ const lexical = (text: string) => ({
64
+ root: { type: "root", children: [{ type: "paragraph", children: [{ type: "text", text }] }] },
65
+ });
66
+
67
+ const memberAuthor = (accountId: string, name: string) => ({
68
+ accountId,
69
+ firstName: name,
70
+ lastName: null,
71
+ displayName: name,
72
+ avatarUrl: null,
73
+ level: 1,
74
+ role: "member",
75
+ });
76
+
77
+ const basePost = (overrides: Partial<CommunityPost> = {}): CommunityPost => ({
78
+ id: "post-1",
79
+ profileId: PROFILE_ID,
80
+ spaceId: "space-1",
81
+ authorAccountId: "someone",
82
+ title: "Tour dates",
83
+ body: lexical("Where next?"),
84
+ bodyText: "Where next?",
85
+ isPinned: false,
86
+ isLocked: false,
87
+ replyCount: 2,
88
+ reactionCount: 0,
89
+ lastActivityAt: null,
90
+ createdAt: "2026-09-30T10:00:00.000Z",
91
+ author: memberAuthor("someone", "Sam"),
92
+ agentAuthor: null,
93
+ media: [],
94
+ reactions: [],
95
+ status: "published",
96
+ ...overrides,
97
+ });
98
+
99
+ const memberReply = (id: string, authorAccountId: string, name: string, parentReplyId: string | null = null): CommunityReply => ({
100
+ id,
101
+ postId: "post-1",
102
+ parentReplyId,
103
+ authorAccountId,
104
+ body: lexical(`${name} says hi`),
105
+ bodyText: `${name} says hi`,
106
+ reactionCount: 0,
107
+ createdAt: "2026-09-30T10:05:00.000Z",
108
+ author: memberAuthor(authorAccountId, name),
109
+ agentAuthor: null,
110
+ media: [],
111
+ reactions: [],
112
+ status: "published",
113
+ });
114
+
115
+ const agentReply = (id: string, parentReplyId: string | null): CommunityReply => ({
116
+ id,
117
+ postId: "post-1",
118
+ parentReplyId,
119
+ authorAccountId: null,
120
+ body: lexical(`Refunds take five days.\n\nSources:\n- [Refunds](${SOURCE_URL})`),
121
+ bodyText: "Refunds take five days.",
122
+ reactionCount: 0,
123
+ createdAt: "2026-09-30T10:06:00.000Z",
124
+ author: null,
125
+ agentAuthor: AGENT_AUTHOR,
126
+ media: [],
127
+ reactions: [],
128
+ status: "published",
129
+ });
130
+
131
+ const memberRoutes = (info: unknown = AGENT): Route[] => [
132
+ meRoute(() => viewer({ state: "member" })),
133
+ get("/public/community-agent", () => info),
134
+ ];
135
+
136
+ const threadRoutes = (thePost: CommunityPost, replies: CommunityReply[]): Route[] => [
137
+ get("/public/community/posts/post-1", () => ({ post: thePost })),
138
+ get("/public/community/posts/post-1/replies", () => ({
139
+ data: replies,
140
+ total: replies.length,
141
+ page: 1,
142
+ pageSize: 100,
143
+ hasNextPage: false,
144
+ })),
145
+ ];
146
+
147
+ function renderIn(ui: ReactNode, opts: { user?: unknown } = { user: ME }) {
148
+ const Wrapper = wrapper(opts);
149
+ return render(<Wrapper>{ui}</Wrapper>);
150
+ }
151
+
152
+ beforeEach(() => socketState.listeners.clear());
153
+ afterEach(() => {
154
+ cleanup();
155
+ restoreAdapter();
156
+ });
157
+
158
+ describe("agent authorship", () => {
159
+ it("a post card shows the agent's name, glyph and AI badge, never the Member fallback", () => {
160
+ installAdapter([]);
161
+ renderIn(
162
+ <CommunityPostCard
163
+ post={basePost({ authorAccountId: null, author: null, agentAuthor: AGENT_AUTHOR })}
164
+ variant="feed"
165
+ />,
166
+ );
167
+ expect(screen.getByText("Assistant")).toBeTruthy();
168
+ expect(screen.getByTestId("community-agent-badge").textContent).toBe("AI");
169
+ expect(screen.getByTestId("community-agent-glyph")).toBeTruthy();
170
+ expect(screen.queryByText("Member")).toBeNull();
171
+ });
172
+
173
+ it("the forum row names the agent with its badge", () => {
174
+ installAdapter([]);
175
+ renderIn(
176
+ <CommunityPostCard post={basePost({ authorAccountId: null, author: null, agentAuthor: AGENT_AUTHOR })} variant="forum" />,
177
+ );
178
+ expect(screen.getByTestId("community-agent-badge")).toBeTruthy();
179
+ expect(screen.queryByText("Member")).toBeNull();
180
+ });
181
+
182
+ it("an agent avatar renders as the image when the creator set one", () => {
183
+ installAdapter([]);
184
+ renderIn(
185
+ <CommunityPostCard
186
+ post={basePost({ authorAccountId: null, author: null, agentAuthor: { ...AGENT_AUTHOR, avatarUrl: "https://cdn.test/a.png" } })}
187
+ variant="feed"
188
+ />,
189
+ );
190
+ expect((screen.getByAltText("Assistant") as HTMLImageElement).src).toBe("https://cdn.test/a.png");
191
+ });
192
+
193
+ it("a post card shows the thinking line only while asked to", () => {
194
+ installAdapter([]);
195
+ const { rerender } = renderIn(<CommunityPostCard post={basePost()} variant="feed" agentThinking={{ name: "Assistant" }} />);
196
+ expect(screen.getByTestId("community-agent-thinking").textContent).toContain("Assistant is thinking");
197
+ const Wrapper = wrapper({ user: ME });
198
+ rerender(
199
+ <Wrapper>
200
+ <CommunityPostCard post={basePost()} variant="feed" agentThinking={null} />
201
+ </Wrapper>,
202
+ );
203
+ expect(screen.queryByTestId("community-agent-thinking")).toBeNull();
204
+ });
205
+
206
+ it("an agent reply in a thread: badge, clickable sources, no member fallback", async () => {
207
+ installAdapter([...memberRoutes(), ...threadRoutes(basePost(), [memberReply("r1", ME.id, "Fan"), agentReply("ai-1", "r1")])]);
208
+ renderIn(<CommunityPostDetail postId="post-1" />);
209
+ const reply = await screen.findByTestId("community-agent-reply");
210
+ expect(within(reply).getByText("Assistant")).toBeTruthy();
211
+ expect(within(reply).getByTestId("community-agent-badge")).toBeTruthy();
212
+ expect(within(reply).queryByText("Member")).toBeNull();
213
+ // The "Sources:" line stays as text; each source reads as its title and
214
+ // links to its page in a new tab, with no raw address on screen.
215
+ expect(reply.textContent).toContain("Sources:");
216
+ expect(reply.textContent).not.toContain(SOURCE_URL);
217
+ const link = within(reply).getByRole("link", { name: "Refunds" }) as HTMLAnchorElement;
218
+ expect(link.href).toBe(SOURCE_URL);
219
+ expect(link.target).toBe("_blank");
220
+ expect(link.rel).toBe("noopener noreferrer");
221
+ });
222
+ });
223
+
224
+ /** The member's own ratings read: answers this account asked for, and nothing else. */
225
+ const RATINGS_PATH = "/public/community-agent/feedback";
226
+ const ratingsRoute = (rows: { entityId: string; feedback: 1 | -1 | null }[]): Route =>
227
+ get(RATINGS_PATH, (req) => {
228
+ const asked = String(req.params?.entityIds ?? "").split(",");
229
+ return { data: rows.filter((r) => asked.includes(r.entityId)) };
230
+ });
231
+
232
+ describe("thumbs up / down in a thread", () => {
233
+ it("hydrates the saved rating on load and sends a new one", async () => {
234
+ installAdapter([
235
+ ...memberRoutes(),
236
+ ...threadRoutes(basePost(), [memberReply("r1", ME.id, "Fan"), agentReply("ai-1", "r1"), agentReply("ai-2", "r1")]),
237
+ ratingsRoute([{ entityId: "ai-1", feedback: 1 }]),
238
+ post(RATINGS_PATH, () => ({ id: "ans", feedback: -1 })),
239
+ ]);
240
+ renderIn(<CommunityPostDetail postId="post-1" />);
241
+ await waitFor(() => expect(screen.getAllByTestId("community-agent-feedback")).toHaveLength(1));
242
+ // One read for the thread, with every agent answer in it.
243
+ expect(calls("get", RATINGS_PATH)).toHaveLength(1);
244
+ expect(calls("get", RATINGS_PATH)[0].params).toEqual({
245
+ profileId: PROFILE_ID,
246
+ entityType: "community_reply",
247
+ entityIds: "ai-1,ai-2",
248
+ });
249
+ const feedback = screen.getByTestId("community-agent-feedback");
250
+ expect(within(feedback).getByRole("button", { name: "Helpful answer" }).getAttribute("aria-pressed")).toBe("true");
251
+ const down = within(feedback).getByRole("button", { name: "Not helpful" });
252
+ fireEvent.click(down);
253
+ expect(down.getAttribute("aria-pressed")).toBe("true");
254
+ await waitFor(() => expect(calls("post", RATINGS_PATH)).toHaveLength(1));
255
+ expect(calls("post", RATINGS_PATH)[0].body).toEqual({
256
+ profileId: PROFILE_ID,
257
+ entityType: "community_reply",
258
+ entityId: "ai-1",
259
+ value: -1,
260
+ });
261
+ });
262
+
263
+ it("offers thumbs only on answers the read returns, whoever wrote the parent", async () => {
264
+ // The parent is the viewer's own reply, but the read says they did not ask: no thumbs.
265
+ installAdapter([
266
+ ...memberRoutes(),
267
+ ...threadRoutes(basePost(), [memberReply("r1", ME.id, "Fan"), agentReply("ai-1", "r1")]),
268
+ ratingsRoute([]),
269
+ ]);
270
+ renderIn(<CommunityPostDetail postId="post-1" />);
271
+ const reply = await screen.findByTestId("community-agent-reply");
272
+ await waitFor(() => expect(calls("get", RATINGS_PATH)).toHaveLength(1));
273
+ await new Promise((r) => setTimeout(r, 20));
274
+ expect(within(reply).queryByTestId("community-agent-feedback")).toBeNull();
275
+ });
276
+
277
+ it("offers thumbs on an answer under someone else's reply when the read returns it", async () => {
278
+ installAdapter([
279
+ ...memberRoutes(),
280
+ ...threadRoutes(basePost(), [memberReply("r1", "someone", "Sam"), agentReply("ai-1", "r1")]),
281
+ ratingsRoute([{ entityId: "ai-1", feedback: null }]),
282
+ ]);
283
+ renderIn(<CommunityPostDetail postId="post-1" />);
284
+ const reply = await screen.findByTestId("community-agent-reply");
285
+ await waitFor(() => expect(within(reply).getByTestId("community-agent-feedback")).toBeTruthy());
286
+ });
287
+
288
+ it("does not read ratings when the AI is off", async () => {
289
+ installAdapter([
290
+ ...memberRoutes({ enabled: false }),
291
+ ...threadRoutes(basePost(), [memberReply("r1", ME.id, "Fan"), agentReply("ai-1", "r1")]),
292
+ ratingsRoute([{ entityId: "ai-1", feedback: 1 }]),
293
+ ]);
294
+ renderIn(<CommunityPostDetail postId="post-1" />);
295
+ await screen.findByTestId("community-agent-reply");
296
+ await waitFor(() => expect(calls("get", "/public/community-agent")).toHaveLength(1));
297
+ await new Promise((r) => setTimeout(r, 20));
298
+ expect(calls("get", RATINGS_PATH)).toHaveLength(0);
299
+ expect(screen.queryByTestId("community-agent-feedback")).toBeNull();
300
+ });
301
+ });
302
+
303
+ describe("thinking and held content in an open thread", () => {
304
+ it("shows the thinking line on thinking and drops it on done", async () => {
305
+ installAdapter([...memberRoutes(), ...threadRoutes(basePost(), [memberReply("r1", ME.id, "Fan")])]);
306
+ renderIn(<CommunityPostDetail postId="post-1" />);
307
+ await screen.findByText("Fan says hi");
308
+ await waitFor(() => expect(socketState.listeners.size).toBeGreaterThan(0));
309
+ deliver("communityAgentThinking", { postId: "post-1", agentId: "agent-1", state: "thinking" });
310
+ await waitFor(() => expect(screen.getByTestId("community-agent-thinking").textContent).toContain("Assistant is thinking"));
311
+ deliver("communityAgentThinking", { postId: "post-1", agentId: "agent-1", state: "done" });
312
+ expect(screen.queryByTestId("community-agent-thinking")).toBeNull();
313
+ });
314
+
315
+ it("clears the thinking line when the answer arrives, and shows the answer", async () => {
316
+ installAdapter([...memberRoutes(), ...threadRoutes(basePost(), [memberReply("r1", ME.id, "Fan")])]);
317
+ renderIn(<CommunityPostDetail postId="post-1" />);
318
+ await screen.findByText("Fan says hi");
319
+ await waitFor(() => expect(socketState.listeners.size).toBeGreaterThan(0));
320
+ deliver("communityAgentThinking", { postId: "post-1", agentId: "agent-1", state: "thinking" });
321
+ deliver("communityNewReply", { spaceId: "space-1", postId: "post-1", reply: agentReply("ai-9", "r1") });
322
+ expect(screen.queryByTestId("community-agent-thinking")).toBeNull();
323
+ expect(await screen.findByTestId("community-agent-reply")).toBeTruthy();
324
+ });
325
+
326
+ it("removes a reply that auto-moderation sent back to the queue", async () => {
327
+ installAdapter([
328
+ ...memberRoutes(),
329
+ ...threadRoutes(basePost(), [memberReply("r1", ME.id, "Fan"), memberReply("r2", "someone", "Sam")]),
330
+ ]);
331
+ renderIn(<CommunityPostDetail postId="post-1" />);
332
+ await screen.findByText("Sam says hi");
333
+ await waitFor(() => expect(socketState.listeners.size).toBeGreaterThan(0));
334
+ deliver("communityContentHeld", { spaceId: "space-1", entityType: "community_reply", postId: "post-1", replyId: "r2" });
335
+ await waitFor(() => expect(screen.queryByText("Sam says hi")).toBeNull());
336
+ expect(screen.getByText("Fan says hi")).toBeTruthy();
337
+ });
338
+
339
+ it("closes the open post for a reader when the post itself is held", async () => {
340
+ installAdapter([...memberRoutes(), ...threadRoutes(basePost(), [])]);
341
+ renderIn(<CommunityPostDetail postId="post-1" />);
342
+ await screen.findByText("Tour dates");
343
+ await waitFor(() => expect(socketState.listeners.size).toBeGreaterThan(0));
344
+ deliver("communityContentHeld", { spaceId: "space-1", entityType: "community_post", postId: "post-1", replyId: null });
345
+ await waitFor(() => expect(screen.getByText("This post is unavailable.")).toBeTruthy());
346
+ });
347
+ });
348
+
349
+ describe("catch me up", () => {
350
+ const many = Array.from({ length: 10 }, (_, i) => memberReply(`r${i}`, "someone", `Member${i}`));
351
+ const summaryPath = "/public/community-agent/posts/post-1/summary";
352
+
353
+ it("is offered on a long thread when the AI is on, and not on a short one", async () => {
354
+ installAdapter([...memberRoutes(), ...threadRoutes(basePost({ replyCount: 10 }), many)]);
355
+ renderIn(<CommunityPostDetail postId="post-1" />);
356
+ expect(await screen.findByRole("button", { name: "Catch me up" })).toBeTruthy();
357
+ cleanup();
358
+ restoreAdapter();
359
+ installAdapter([...memberRoutes(), ...threadRoutes(basePost({ replyCount: 9 }), many.slice(0, 9))]);
360
+ renderIn(<CommunityPostDetail postId="post-1" />);
361
+ await screen.findByText("Member0 says hi");
362
+ await new Promise((r) => setTimeout(r, 20));
363
+ expect(screen.queryByRole("button", { name: "Catch me up" })).toBeNull();
364
+ });
365
+
366
+ it("is not offered when the AI is off", async () => {
367
+ installAdapter([...memberRoutes({ enabled: false }), ...threadRoutes(basePost({ replyCount: 10 }), many)]);
368
+ renderIn(<CommunityPostDetail postId="post-1" />);
369
+ await screen.findByText("Member0 says hi");
370
+ await waitFor(() => expect(calls("get", "/public/community-agent")).toHaveLength(1));
371
+ await new Promise((r) => setTimeout(r, 20));
372
+ expect(screen.queryByRole("button", { name: "Catch me up" })).toBeNull();
373
+ });
374
+
375
+ it("shows the private summary, labelled as the AI's, and dismisses it", async () => {
376
+ installAdapter([
377
+ ...memberRoutes(),
378
+ ...threadRoutes(basePost({ replyCount: 10 }), many),
379
+ post(summaryPath, () => ({ summary: "Everyone wants Berlin.", agentName: "Assistant" })),
380
+ ]);
381
+ renderIn(<CommunityPostDetail postId="post-1" />);
382
+ fireEvent.click(await screen.findByRole("button", { name: "Catch me up" }));
383
+ const panel = await screen.findByTestId("community-catch-me-up-panel");
384
+ expect(panel.textContent).toContain("Everyone wants Berlin.");
385
+ expect(within(panel).getByTestId("community-agent-badge")).toBeTruthy();
386
+ expect(panel.textContent).toContain("only you can see it");
387
+ expect(calls("post", summaryPath)[0].body).toEqual({ profileId: PROFILE_ID, communityId: COMMUNITY.id });
388
+ fireEvent.click(within(panel).getByRole("button", { name: "Dismiss summary" }));
389
+ expect(screen.queryByTestId("community-catch-me-up-panel")).toBeNull();
390
+ });
391
+
392
+ it.each([
393
+ [409, "reached today's limit"],
394
+ [400, "can't summarise this thread"],
395
+ ])("says so plainly on a %s", async (status, copy) => {
396
+ installAdapter([
397
+ ...memberRoutes(),
398
+ ...threadRoutes(basePost({ replyCount: 10 }), many),
399
+ post(summaryPath, () => {
400
+ throw { status };
401
+ }),
402
+ ]);
403
+ renderIn(<CommunityPostDetail postId="post-1" />);
404
+ fireEvent.click(await screen.findByRole("button", { name: "Catch me up" }));
405
+ const error = await screen.findByTestId("community-catch-me-up-error");
406
+ expect(error.textContent).toContain(copy);
407
+ expect(screen.queryByTestId("community-catch-me-up-panel")).toBeNull();
408
+ });
409
+ });
410
+
411
+ describe("chat", () => {
412
+ const agentMessage = (overrides: Partial<ChatMessage> = {}): ChatMessage => ({
413
+ id: "m-ai",
414
+ channelId: "chan-ai",
415
+ content: `Refunds take five days.\nSources:\n- [Refunds](${SOURCE_URL})`,
416
+ senderAccountId: null,
417
+ senderAgentId: "agent-1",
418
+ senderAgentAvatarUrl: null,
419
+ senderFirstName: "Assistant",
420
+ senderLastName: "",
421
+ isPinned: false,
422
+ attachments: [],
423
+ parentMessageId: null,
424
+ replyCount: 0,
425
+ lastReplyAt: null,
426
+ createdAt: "2026-09-30T10:00:00.000Z",
427
+ ...overrides,
428
+ });
429
+
430
+ it("an agent message shows its name and badge, a clickable source, and is never on my side", () => {
431
+ installAdapter([]);
432
+ renderIn(<MessageBubble message={agentMessage()} mine showThreadControls={false} />);
433
+ const bubble = screen.getByTestId("chat-agent-message");
434
+ expect(bubble.style.alignSelf).toBe("flex-start");
435
+ expect(within(bubble).getByText("Assistant")).toBeTruthy();
436
+ expect(within(bubble).getByTestId("community-agent-badge")).toBeTruthy();
437
+ expect(bubble.textContent).not.toContain(SOURCE_URL);
438
+ const link = within(bubble).getByRole("link", { name: "Refunds" }) as HTMLAnchorElement;
439
+ expect(link.target).toBe("_blank");
440
+ expect(link.rel).toBe("noopener noreferrer");
441
+ });
442
+
443
+ const channelsRoute = (channels: unknown[]): Route => get("/public/chat/channels", () => channels);
444
+ const messagesRoute = (messages: ChatMessage[]): Route =>
445
+ get(/^\/public\/chat\/channels\/[^/]+\/messages$/, () => ({ data: messages, total: messages.length, page: 1, limit: 50 }));
446
+
447
+ it("opens the agent DM from the chat sidebar and shows it with the agent's badge", async () => {
448
+ installAdapter([
449
+ ...memberRoutes(),
450
+ channelsRoute([]),
451
+ messagesRoute([]),
452
+ get(/^\/public\/chat\/channels\/[^/]+\/pinned$/, () => []),
453
+ get("/public/chat/unread-counts", () => []),
454
+ get("/public/chat/dm/directory", () => []),
455
+ post("/public/community-agent/dm", () => ({ channelId: "chan-ai", agent: AGENT })),
456
+ ]);
457
+ renderIn(<ChatRoom />);
458
+ const open = await screen.findByTestId("chat-agent-dm-open");
459
+ expect(open.textContent).toContain("Message Assistant");
460
+ fireEvent.click(open);
461
+ await waitFor(() => expect(calls("post", "/public/community-agent/dm")).toHaveLength(1));
462
+ expect(calls("post", "/public/community-agent/dm")[0].body).toEqual({ profileId: PROFILE_ID, communityId: COMMUNITY.id });
463
+ // The DM's messages are read: the panel opened on the agent's channel.
464
+ await waitFor(() => expect(calls("get", "/public/chat/channels/chan-ai/messages")).toHaveLength(1));
465
+ const header = document.querySelector("header")!;
466
+ expect(within(header).getByText("Assistant")).toBeTruthy();
467
+ expect(within(header).getByTestId("community-agent-badge")).toBeTruthy();
468
+ });
469
+
470
+ it("says so when the DM cannot be opened", async () => {
471
+ installAdapter([
472
+ ...memberRoutes(),
473
+ channelsRoute([]),
474
+ get("/public/chat/unread-counts", () => []),
475
+ get("/public/chat/dm/directory", () => []),
476
+ post("/public/community-agent/dm", () => {
477
+ throw { status: 403 };
478
+ }),
479
+ ]);
480
+ renderIn(<ChatRoom />);
481
+ fireEvent.click(await screen.findByTestId("chat-agent-dm-open"));
482
+ expect((await screen.findByRole("alert")).textContent).toContain("couldn't open this conversation");
483
+ });
484
+
485
+ it("offers no DM entry when the creator switched agent DMs off", async () => {
486
+ installAdapter([
487
+ ...memberRoutes({ ...AGENT, dmEnabled: false }),
488
+ channelsRoute([]),
489
+ get("/public/chat/unread-counts", () => []),
490
+ get("/public/chat/dm/directory", () => []),
491
+ ]);
492
+ renderIn(<ChatRoom />);
493
+ await waitFor(() => expect(calls("get", "/public/community-agent")).toHaveLength(1));
494
+ await new Promise((r) => setTimeout(r, 20));
495
+ expect(screen.queryByTestId("chat-agent-dm-open")).toBeNull();
496
+ });
497
+
498
+ it("lists an existing agent DM with its badge, rates answers in it, and shows its thinking", async () => {
499
+ const agentDm = { id: "chan-ai", name: "Assistant", type: "dm", description: null, otherParticipant: null, dmKey: "agent:agent-1:account-1" };
500
+ installAdapter([
501
+ ...memberRoutes(),
502
+ channelsRoute([agentDm]),
503
+ messagesRoute([agentMessage()]),
504
+ get(/^\/public\/chat\/channels\/[^/]+\/pinned$/, () => []),
505
+ get("/public/chat/unread-counts", () => []),
506
+ get("/public/chat/dm/directory", () => []),
507
+ post("/public/chat/read-receipts", () => ({})),
508
+ ratingsRoute([{ entityId: "m-ai", feedback: null }]),
509
+ post("/public/community-agent/feedback", () => ({ id: "a", feedback: -1 })),
510
+ ]);
511
+ renderIn(<ChatRoom />);
512
+ // The DM row carries the badge, and the separate entry point is not repeated.
513
+ await waitFor(() => expect(screen.getAllByTestId("community-agent-badge").length).toBeGreaterThan(0));
514
+ expect(screen.queryByTestId("chat-agent-dm-open")).toBeNull();
515
+ fireEvent.click(screen.getByRole("button", { name: /Assistant/ }));
516
+
517
+ const bubble = await screen.findByTestId("chat-agent-message");
518
+ fireEvent.click(within(bubble).getByRole("button", { name: "Not helpful" }));
519
+ await waitFor(() => expect(calls("post", "/public/community-agent/feedback")).toHaveLength(1));
520
+ expect(calls("post", "/public/community-agent/feedback")[0].body).toEqual({
521
+ profileId: PROFILE_ID,
522
+ entityType: "chat_message",
523
+ entityId: "m-ai",
524
+ value: -1,
525
+ });
526
+
527
+ deliver("chatAgentThinking", { channelId: "chan-ai", parentMessageId: null, agentId: "agent-1", state: "thinking" });
528
+ expect(screen.getByTestId("community-agent-thinking").textContent).toContain("Assistant is thinking");
529
+ deliver("chatAgentThinking", { channelId: "chan-ai", parentMessageId: null, agentId: "agent-1", state: "done" });
530
+ expect(screen.queryByTestId("community-agent-thinking")).toBeNull();
531
+ });
532
+
533
+ it("a channel thread: thumbs on the answers the read returns, hydrated, even when the viewer started nothing", async () => {
534
+ const parent = agentMessage({ id: "m-root", channelId: "chan-1", senderAgentId: null, senderAccountId: "someone", senderFirstName: "Sam", content: "@Assistant when?" });
535
+ const answers = [
536
+ agentMessage({ id: "m-t1", channelId: "chan-1", parentMessageId: "m-root" }),
537
+ agentMessage({ id: "m-t2", channelId: "chan-1", parentMessageId: "m-root" }),
538
+ ];
539
+ installAdapter([
540
+ ...memberRoutes(),
541
+ get("/public/chat/messages/m-root/replies", () => ({ data: answers, total: 2, page: 1, limit: 50 })),
542
+ ratingsRoute([{ entityId: "m-t2", feedback: -1 }]),
543
+ ]);
544
+ renderIn(<ChatThreadPanel parentMessage={parent} socket={null} currentAccountId={ME.id} onClose={() => {}} border="1px solid" />);
545
+ await waitFor(() => expect(screen.getAllByTestId("community-agent-feedback")).toHaveLength(1));
546
+ expect(calls("get", RATINGS_PATH)[0].params).toEqual({ profileId: PROFILE_ID, entityType: "chat_message", entityIds: "m-t1,m-t2" });
547
+ const feedback = screen.getByTestId("community-agent-feedback");
548
+ expect(within(feedback).getByRole("button", { name: "Not helpful" }).getAttribute("aria-pressed")).toBe("true");
549
+ });
550
+
551
+ it("the agent DM: no thumbs on an answer the read does not return", async () => {
552
+ const agentDm = { id: "chan-ai", name: "Assistant", type: "dm", description: null, otherParticipant: null, dmKey: "agent:agent-1:account-1" };
553
+ installAdapter([
554
+ ...memberRoutes(),
555
+ channelsRoute([agentDm]),
556
+ messagesRoute([agentMessage()]),
557
+ get(/^\/public\/chat\/channels\/[^/]+\/pinned$/, () => []),
558
+ get("/public/chat/unread-counts", () => []),
559
+ get("/public/chat/dm/directory", () => []),
560
+ post("/public/chat/read-receipts", () => ({})),
561
+ ratingsRoute([]),
562
+ ]);
563
+ renderIn(<ChatRoom />);
564
+ await waitFor(() => expect(screen.getAllByTestId("community-agent-badge").length).toBeGreaterThan(0));
565
+ fireEvent.click(screen.getByRole("button", { name: /Assistant/ }));
566
+ const bubble = await screen.findByTestId("chat-agent-message");
567
+ await waitFor(() => expect(calls("get", RATINGS_PATH)).toHaveLength(1));
568
+ await new Promise((r) => setTimeout(r, 20));
569
+ expect(within(bubble).queryByTestId("community-agent-feedback")).toBeNull();
570
+ });
571
+ });