@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
@@ -5,15 +5,29 @@ import { useCommunityThread } from "../../headless/community/useCommunityThread"
5
5
  import { findReplyNode, type ReplyNode } from "../../headless/community/replyTree";
6
6
  import {
7
7
  communityMemberName,
8
+ isCommunityContentPending,
8
9
  useReportCommunityContent,
9
10
  type CommunityReply,
10
11
  } from "../../../data/queries/useCommunity";
11
12
  import { Loading } from "../Loading";
12
13
  import { CommunityComposer } from "./CommunityComposer";
13
14
  import { CommunityMediaGallery } from "./CommunityMediaGallery";
15
+ import { CommunityPollCard } from "./CommunityPollCard";
16
+ import { CommunityLinkPreview } from "./CommunityLinkPreview";
17
+ import { CommunityModeratorMenu } from "./CommunityModeratorMenu";
18
+ import { CommunityPendingBadge, CommunityRemovedNotice } from "./CommunityModerationState";
14
19
  import { CommunityReactionBar } from "./CommunityReactionBar";
15
20
  import { CommunityRichText } from "./CommunityRichText";
16
21
  import { LevelBadge, MemberAvatar } from "./MemberAvatar";
22
+ import {
23
+ CommunityAgentAvatar,
24
+ CommunityAgentFeedback,
25
+ CommunityAgentName,
26
+ CommunityAgentThinking,
27
+ CommunityCatchMeUp,
28
+ } from "./CommunityAgent";
29
+ import { useCommunityAgent } from "../../../data/queries/useCommunityAgent";
30
+ import { CATCH_ME_UP_MIN_REPLIES, useCommunityAgentAnswerRatings } from "../../headless/community/useCommunityAgent";
17
31
  import { timeAgo } from "./util";
18
32
 
19
33
  /** How many nesting levels are RENDERED before "Continue this thread". The
@@ -33,6 +47,14 @@ export function CommunityPostDetail({ postId, onOpenProfile, onBack }: Community
33
47
  const t = useThemeTokens();
34
48
  const thread = useCommunityThread(postId);
35
49
  const report = useReportCommunityContent();
50
+ // The community AI: the "Catch me up" panel and the name on its thinking line.
51
+ const { agent } = useCommunityAgent();
52
+ // The viewer's own ratings on the AI answers here: who may rate, and how they did.
53
+ const agentAnswerIds = useMemo(
54
+ () => thread.replies.filter((r) => r.agentAuthor).map((r) => r.id),
55
+ [thread.replies],
56
+ );
57
+ const ratings = useCommunityAgentAnswerRatings("community_reply", agentAnswerIds);
36
58
  const border = `1px solid ${t.border}`;
37
59
 
38
60
  // "Continue thread" re-roots the rendered tree on a deep reply (Reddit-style).
@@ -58,6 +80,8 @@ export function CommunityPostDetail({ postId, onOpenProfile, onBack }: Community
58
80
 
59
81
  const post = thread.post;
60
82
  const locked = post.isLocked;
83
+ // The author's own held post: no replies or reactions until it is approved.
84
+ const pending = isCommunityContentPending(post);
61
85
 
62
86
  const doReport = (entityId: string, entityType: "community_post" | "community_reply") => {
63
87
  if (reported.has(entityId)) return;
@@ -106,32 +130,37 @@ export function CommunityPostDetail({ postId, onOpenProfile, onBack }: Community
106
130
  const renderNode = (node: ReplyNode, renderedDepth: number): React.ReactNode => {
107
131
  const reply = node.reply;
108
132
  const capped = renderedDepth >= REPLY_DISPLAY_DEPTH - 1 && node.children.length > 0;
133
+ const replyPending = isCommunityContentPending(reply);
109
134
 
110
135
  return (
111
136
  <div key={reply.id} style={{ display: "flex", flexDirection: "column", gap: 8 }}>
112
137
  <ReplyItem
113
138
  reply={reply}
139
+ showFeedback={!!reply.agentAuthor && ratings.canRate(reply.id)}
140
+ savedRating={ratings.ratingOf(reply.id)}
114
141
  onOpenProfile={onOpenProfile}
115
142
  onReact={(emoji) => thread.react("community_reply", reply.id, emoji)}
116
143
  extraActions={
117
- <>
118
- {!locked && (
119
- <button
120
- onClick={() => setReplyingTo((prev) => (prev === reply.id ? null : reply.id))}
121
- style={{
122
- background: "none",
123
- border: "none",
124
- cursor: "pointer",
125
- color: t.muted,
126
- fontSize: 12,
127
- padding: "2px 4px",
128
- }}
129
- >
130
- Reply
131
- </button>
132
- )}
133
- {reportButton(reply.id, "community_reply")}
134
- </>
144
+ replyPending ? null : (
145
+ <>
146
+ {!locked && (
147
+ <button
148
+ onClick={() => setReplyingTo((prev) => (prev === reply.id ? null : reply.id))}
149
+ style={{
150
+ background: "none",
151
+ border: "none",
152
+ cursor: "pointer",
153
+ color: t.muted,
154
+ fontSize: 12,
155
+ padding: "2px 4px",
156
+ }}
157
+ >
158
+ Reply
159
+ </button>
160
+ )}
161
+ {reportButton(reply.id, "community_reply")}
162
+ </>
163
+ )
135
164
  }
136
165
  />
137
166
 
@@ -143,7 +172,7 @@ export function CommunityPostDetail({ postId, onOpenProfile, onBack }: Community
143
172
  <CommunityComposer
144
173
  compact
145
174
  autoFocus
146
- placeholder={`Reply to ${communityMemberName(reply.author)}…`}
175
+ placeholder={`Reply to ${reply.agentAuthor ? reply.agentAuthor.name : communityMemberName(reply.author)}…`}
147
176
  submitLabel="Reply"
148
177
  onSubmit={submitReply}
149
178
  onCancel={() => setReplyingTo(null)}
@@ -198,48 +227,69 @@ export function CommunityPostDetail({ postId, onOpenProfile, onBack }: Community
198
227
  </button>
199
228
  )}
200
229
 
201
- {/* Post */}
202
- <article style={{ border, borderRadius: t.cornerRadius, background: t.surface, padding: 18 }}>
203
- <header style={{ display: "flex", alignItems: "center", gap: 10 }}>
204
- <MemberAvatar member={post.author} size={40} onClick={() => post.author && onOpenProfile?.(post.author.accountId)} />
205
- <div style={{ minWidth: 0, flex: 1 }}>
206
- <div style={{ display: "flex", alignItems: "center", gap: 6 }}>
207
- <button
208
- onClick={() => post.author && onOpenProfile?.(post.author.accountId)}
209
- style={{ background: "none", border: "none", padding: 0, cursor: "pointer", color: t.text, fontWeight: 700, fontSize: 15 }}
210
- >
211
- {communityMemberName(post.author)}
212
- </button>
213
- <LevelBadge level={post.author?.level} />
230
+ {/* Post. Just removed by this moderator: the undo, in its place. */}
231
+ {post.archivedAt ? (
232
+ <CommunityRemovedNotice target={{ type: "post", post }} />
233
+ ) : (
234
+ <article style={{ border, borderRadius: t.cornerRadius, background: t.surface, padding: 18 }}>
235
+ <header style={{ display: "flex", alignItems: "center", gap: 10 }}>
236
+ {post.agentAuthor ? (
237
+ <CommunityAgentAvatar agent={post.agentAuthor} size={40} />
238
+ ) : (
239
+ <MemberAvatar member={post.author} size={40} onClick={() => post.author && onOpenProfile?.(post.author.accountId)} />
240
+ )}
241
+ <div style={{ minWidth: 0, flex: 1 }}>
242
+ <div style={{ display: "flex", alignItems: "center", gap: 6 }}>
243
+ {post.agentAuthor ? (
244
+ <CommunityAgentName agent={post.agentAuthor} fontSize={15} fontWeight={700} />
245
+ ) : (
246
+ <>
247
+ <button
248
+ onClick={() => post.author && onOpenProfile?.(post.author.accountId)}
249
+ style={{ background: "none", border: "none", padding: 0, cursor: "pointer", color: t.text, fontWeight: 700, fontSize: 15 }}
250
+ >
251
+ {communityMemberName(post.author)}
252
+ </button>
253
+ <LevelBadge level={post.author?.level} />
254
+ </>
255
+ )}
256
+ </div>
257
+ <div style={{ fontSize: 12, color: t.muted }}>{timeAgo(post.createdAt)}</div>
214
258
  </div>
215
- <div style={{ fontSize: 12, color: t.muted }}>{timeAgo(post.createdAt)}</div>
216
- </div>
217
- {locked && (
218
- <span style={{ display: "inline-flex", alignItems: "center", gap: 4, fontSize: 12, color: t.muted }}>
219
- <Lock size={12} /> Locked
220
- </span>
221
- )}
222
- </header>
259
+ {pending && <CommunityPendingBadge />}
260
+ {locked && (
261
+ <span style={{ display: "inline-flex", alignItems: "center", gap: 4, fontSize: 12, color: t.muted }}>
262
+ <Lock size={12} /> Locked
263
+ </span>
264
+ )}
265
+ <CommunityModeratorMenu target={{ type: "post", post }} />
266
+ </header>
223
267
 
224
- {post.title && (
225
- <h2 style={{ margin: "12px 0 6px", fontSize: 20, fontFamily: t.headingFontFamily, color: t.text }}>{post.title}</h2>
226
- )}
227
- <div style={{ marginTop: post.title ? 0 : 12 }}>
228
- <CommunityRichText body={post.body} />
229
- </div>
230
- <CommunityMediaGallery media={post.media ?? []} />
268
+ {post.title && (
269
+ <h2 style={{ margin: "12px 0 6px", fontSize: 20, fontFamily: t.headingFontFamily, color: t.text }}>{post.title}</h2>
270
+ )}
271
+ <div style={{ marginTop: post.title ? 0 : 12 }}>
272
+ <CommunityRichText body={post.body} linkify={!!post.agentAuthor} />
273
+ </div>
274
+ <CommunityMediaGallery media={post.media ?? []} />
275
+ <CommunityLinkPreview preview={post.linkPreview} />
276
+ {post.poll && <CommunityPollCard post={post} disabled={pending} />}
231
277
 
232
- <footer style={{ marginTop: 14 }}>
233
- <CommunityReactionBar reactions={post.reactions ?? []} onToggle={(emoji) => thread.react("community_post", post.id, emoji)}>
234
- {reportButton(post.id, "community_post")}
235
- </CommunityReactionBar>
236
- </footer>
237
- </article>
278
+ {!pending && (
279
+ <footer style={{ marginTop: 14 }}>
280
+ <CommunityReactionBar reactions={post.reactions ?? []} onToggle={(emoji) => thread.react("community_post", post.id, emoji)}>
281
+ {reportButton(post.id, "community_post")}
282
+ </CommunityReactionBar>
283
+ </footer>
284
+ )}
285
+ </article>
286
+ )}
238
287
 
239
288
  {/* Top-level composer: closed until asked for, and hidden rather than
240
289
  unmounted while a nested reply box is open, so a half-written
241
- top-level reply survives the detour of replying to someone else. */}
242
- {locked ? (
290
+ top-level reply survives the detour of replying to someone else.
291
+ None at all on a held or just-removed post. */}
292
+ {pending || post.archivedAt ? null : locked ? (
243
293
  <div style={{ border, borderRadius: t.cornerRadius, padding: 14, textAlign: "center", color: t.muted, fontSize: 14 }}>
244
294
  <Lock size={13} style={{ verticalAlign: -2, marginRight: 6 }} />
245
295
  This post is locked: replies are closed.
@@ -273,6 +323,11 @@ export function CommunityPostDetail({ postId, onOpenProfile, onBack }: Community
273
323
  )}
274
324
  </div>
275
325
 
326
+ {/* A private summary of a long thread, for this member only. */}
327
+ {agent && !pending && !post.archivedAt && post.replyCount >= CATCH_ME_UP_MIN_REPLIES && (
328
+ <CommunityCatchMeUp postId={post.id} agent={agent} />
329
+ )}
330
+
276
331
  {thread.repliesLoading && !thread.replies.length ? (
277
332
  <Loading />
278
333
  ) : (
@@ -281,6 +336,8 @@ export function CommunityPostDetail({ postId, onOpenProfile, onBack }: Community
281
336
  </div>
282
337
  )}
283
338
 
339
+ {thread.agentThinking && <CommunityAgentThinking name={agent?.name} />}
340
+
284
341
  {thread.hasMore && (
285
342
  <button
286
343
  onClick={() => thread.fetchNextPage()}
@@ -312,39 +369,68 @@ function ReplyItem({
312
369
  onOpenProfile,
313
370
  onReact,
314
371
  extraActions,
372
+ showFeedback = false,
373
+ savedRating = null,
315
374
  }: {
316
375
  reply: CommunityReply;
317
376
  onOpenProfile?: (accountId: string) => void;
318
377
  onReact: (emoji: string) => void;
319
378
  extraActions?: React.ReactNode;
379
+ /** An AI answer to this viewer's own question: offer thumbs up / down. */
380
+ showFeedback?: boolean;
381
+ /** Their saved rating on it. */
382
+ savedRating?: 1 | -1 | null;
320
383
  }) {
321
384
  const t = useThemeTokens();
385
+ const pending = isCommunityContentPending(reply);
386
+ // The community AI wrote it: its name and badge, never the "Member" fallback.
387
+ const agent = reply.agentAuthor ?? null;
388
+
389
+ // Just removed by this moderator: the undo, in its place. Its children stay
390
+ // visible beneath it, as the thread renders them.
391
+ if (reply.archivedAt) return <CommunityRemovedNotice target={{ type: "reply", reply }} />;
322
392
 
323
393
  return (
324
- <div style={{ display: "flex", gap: 10 }}>
325
- <MemberAvatar member={reply.author} size={32} onClick={() => reply.author && onOpenProfile?.(reply.author.accountId)} />
394
+ <div style={{ display: "flex", gap: 10 }} data-testid={agent ? "community-agent-reply" : undefined}>
395
+ {agent ? (
396
+ <CommunityAgentAvatar agent={agent} size={32} />
397
+ ) : (
398
+ <MemberAvatar member={reply.author} size={32} onClick={() => reply.author && onOpenProfile?.(reply.author.accountId)} />
399
+ )}
326
400
  <div style={{ flex: 1, minWidth: 0 }}>
327
- <div style={{ display: "flex", alignItems: "center", gap: 6 }}>
328
- <button
329
- onClick={() => reply.author && onOpenProfile?.(reply.author.accountId)}
330
- style={{ background: "none", border: "none", padding: 0, cursor: "pointer", color: t.text, fontWeight: 600, fontSize: 13 }}
331
- >
332
- {communityMemberName(reply.author)}
333
- </button>
334
- <LevelBadge level={reply.author?.level} />
401
+ <div style={{ display: "flex", alignItems: "center", gap: 6, flexWrap: "wrap" }}>
402
+ {agent ? (
403
+ <CommunityAgentName agent={agent} fontSize={13} />
404
+ ) : (
405
+ <>
406
+ <button
407
+ onClick={() => reply.author && onOpenProfile?.(reply.author.accountId)}
408
+ style={{ background: "none", border: "none", padding: 0, cursor: "pointer", color: t.text, fontWeight: 600, fontSize: 13 }}
409
+ >
410
+ {communityMemberName(reply.author)}
411
+ </button>
412
+ <LevelBadge level={reply.author?.level} />
413
+ </>
414
+ )}
335
415
  <span style={{ fontSize: 11, color: t.muted }}>{timeAgo(reply.createdAt)}</span>
416
+ {pending && <CommunityPendingBadge />}
417
+ <CommunityModeratorMenu target={{ type: "reply", reply }} className="fg:ml-auto" />
336
418
  </div>
337
419
 
338
420
  <div style={{ marginTop: 2 }}>
339
- <CommunityRichText body={reply.body} />
421
+ <CommunityRichText body={reply.body} linkify={!!agent} />
340
422
  </div>
341
423
  <CommunityMediaGallery media={reply.media ?? []} />
342
424
 
343
- <div style={{ marginTop: 6 }}>
344
- <CommunityReactionBar reactions={reply.reactions ?? []} onToggle={onReact}>
345
- {extraActions}
346
- </CommunityReactionBar>
347
- </div>
425
+ {/* A held reply takes no reactions until it is approved. */}
426
+ {!pending && (
427
+ <div style={{ marginTop: 6 }}>
428
+ <CommunityReactionBar reactions={reply.reactions ?? []} onToggle={onReact}>
429
+ {extraActions}
430
+ {showFeedback && <CommunityAgentFeedback entityType="community_reply" entityId={reply.id} saved={savedRating} />}
431
+ </CommunityReactionBar>
432
+ </div>
433
+ )}
348
434
  </div>
349
435
  </div>
350
436
  );
@@ -1,24 +1,64 @@
1
+ import { createContext, useContext, type ReactNode } from "react";
1
2
  import { useThemeTokens } from "../../theme/ForgeThemeProvider";
2
3
  import type { LexicalNodeJson } from "../../headless/community/lexical";
4
+ import { lexicalNodeText, parseWikiContent, wikiImageSource } from "../../headless/communities/useCommunityWiki";
5
+ import { linkifyText } from "../../headless/community/useCommunityAgent";
6
+
7
+ /** A private Knowledge Base image (`kbmedia:<id>`) handed to the caller to resolve. */
8
+ export interface CommunityRichTextPrivateImage {
9
+ mediaId: string;
10
+ alt: string;
11
+ /** Sits inside a paragraph rather than on its own line. */
12
+ inline: boolean;
13
+ }
14
+
15
+ /** Set only by a caller that can resolve private images (the wiki page view). */
16
+ const PrivateImageContext = createContext<((image: CommunityRichTextPrivateImage) => ReactNode) | null>(null);
17
+
18
+ /** Set for plain-text bodies whose addresses should be clickable (the community AI's answers). */
19
+ const LinkifyContext = createContext(false);
3
20
 
4
21
  export interface CommunityRichTextProps {
5
- /** Lexical editor state JSON (community_posts.body / community_replies.body). */
22
+ /** Lexical editor state JSON (community_posts.body / community_replies.body, or a wiki page). */
6
23
  body: unknown;
7
24
  /** Truncate to a preview (clamps lines via CSS). */
8
25
  clampLines?: number;
26
+ /**
27
+ * Anchor ids for the top-level headings, in document order, skipping empty
28
+ * ones (what `extractWikiToc` returns), so a table of contents can link to them.
29
+ */
30
+ headingIds?: string[];
31
+ /**
32
+ * Renders a PRIVATE Knowledge Base image (`kbmedia:<id>`). Only the wiki page
33
+ * view passes it, because only it can ask for a signed address. Without it a
34
+ * private image renders nothing; a plain http(s) image always renders.
35
+ */
36
+ renderPrivateImage?: (image: CommunityRichTextPrivateImage) => ReactNode;
37
+ /**
38
+ * Make bare http(s) addresses in the text clickable and keep line breaks.
39
+ * For the community AI's answers, written as plain text with a "Sources:"
40
+ * list of addresses; a member's own text is never rewritten.
41
+ */
42
+ linkify?: boolean;
9
43
  }
10
44
 
11
45
  /**
12
- * Dependency-free renderer for the Lexical states community posts store.
13
- * Handles the node set our composer emits (paragraph/text/mention/linebreak)
14
- * plus the common rich nodes an admin-side Lexical editor could produce
15
- * (heading/quote/list/listitem/link/code). Unknown nodes fall back to their
16
- * children/text so content never disappears.
46
+ * Dependency-free renderer for Lexical states: community posts and replies,
47
+ * and wiki pages written in the Knowledge Base editor. Handles the node set our
48
+ * composer emits (paragraph/text/mention/linebreak) plus the rich nodes the
49
+ * Knowledge Base editor produces (heading, quote, list, link, code, divider,
50
+ * table, image). Unknown nodes fall back to their children/text so content
51
+ * never disappears.
17
52
  */
18
- export function CommunityRichText({ body, clampLines }: CommunityRichTextProps) {
53
+ export function CommunityRichText({ body, clampLines, headingIds, renderPrivateImage, linkify = false }: CommunityRichTextProps) {
19
54
  const t = useThemeTokens();
20
- const root = extractRoot(body);
55
+ const root = parseWikiContent(body)?.root ?? null;
21
56
  if (!root) return null;
57
+ let headingIndex = 0;
58
+ const anchorFor = (node: LexicalNodeJson): string | undefined => {
59
+ if (!headingIds || node.type !== "heading" || !lexicalNodeText(node).trim()) return undefined;
60
+ return headingIds[headingIndex++];
61
+ };
22
62
 
23
63
  const clampStyle: React.CSSProperties = clampLines
24
64
  ? {
@@ -30,23 +70,63 @@ export function CommunityRichText({ body, clampLines }: CommunityRichTextProps)
30
70
  : {};
31
71
 
32
72
  return (
33
- <div style={{ color: t.text, fontSize: 15, lineHeight: 1.55, wordBreak: "break-word", ...clampStyle }}>
34
- {(root.children ?? []).map((node, i) => (
35
- <BlockNode key={i} node={node} />
36
- ))}
37
- </div>
73
+ <PrivateImageContext.Provider value={renderPrivateImage ?? null}>
74
+ <LinkifyContext.Provider value={linkify}>
75
+ <div
76
+ style={{
77
+ color: t.text,
78
+ fontSize: 15,
79
+ lineHeight: 1.55,
80
+ wordBreak: "break-word",
81
+ ...(linkify ? { whiteSpace: "pre-line" as const } : {}),
82
+ ...clampStyle,
83
+ }}
84
+ >
85
+ {(root.children ?? []).map((node, i) => (
86
+ <BlockNode key={i} node={node} anchorId={anchorFor(node)} />
87
+ ))}
88
+ </div>
89
+ </LinkifyContext.Provider>
90
+ </PrivateImageContext.Provider>
38
91
  );
39
92
  }
40
93
 
41
- function extractRoot(body: unknown): LexicalNodeJson | null {
42
- if (!body || typeof body !== "object") return null;
43
- const maybe = body as { root?: LexicalNodeJson; children?: LexicalNodeJson[] };
44
- if (maybe.root) return maybe.root;
45
- if (Array.isArray(maybe.children)) return maybe as LexicalNodeJson;
94
+ /**
95
+ * A Knowledge Base image. A plain http(s) address renders as it is; a private
96
+ * `kbmedia:` reference goes to the caller's resolver, or renders nothing; any
97
+ * other `src` never renders.
98
+ */
99
+ function RichImage({ node, inline }: { node: LexicalNodeJson; inline: boolean }) {
100
+ const t = useThemeTokens();
101
+ const renderPrivate = useContext(PrivateImageContext);
102
+ const source = wikiImageSource(node.src);
103
+ const alt = typeof node.altText === "string" ? node.altText : "";
104
+ if (!source) return null;
105
+ if (source.kind === "private")
106
+ return renderPrivate ? <>{renderPrivate({ mediaId: source.mediaId, alt, inline })}</> : null;
107
+ return (
108
+ <img
109
+ src={source.url}
110
+ alt={alt}
111
+ loading="lazy"
112
+ style={
113
+ inline
114
+ ? { maxWidth: "100%", height: "auto", borderRadius: t.cornerRadius, verticalAlign: "middle" }
115
+ : { display: "block", maxWidth: "100%", height: "auto", margin: "0.6em 0", borderRadius: t.cornerRadius }
116
+ }
117
+ />
118
+ );
119
+ }
120
+
121
+ /** Only web and mail links render as links; anything else (`javascript:` and friends) is dropped. */
122
+ function safeHref(url: unknown): string | null {
123
+ if (typeof url !== "string" || !url.trim()) return null;
124
+ const value = url.trim();
125
+ if (/^(https?:|mailto:|tel:)/i.test(value) || value.startsWith("/") || value.startsWith("#")) return value;
46
126
  return null;
47
127
  }
48
128
 
49
- function BlockNode({ node }: { node: LexicalNodeJson }) {
129
+ function BlockNode({ node, anchorId }: { node: LexicalNodeJson; anchorId?: string }) {
50
130
  const t = useThemeTokens();
51
131
  const children = (node.children ?? []).map((child, i) => <InlineNode key={i} node={child} />);
52
132
 
@@ -62,7 +142,47 @@ function BlockNode({ node }: { node: LexicalNodeJson }) {
62
142
  | "h4"
63
143
  | "h5"
64
144
  | "h6";
65
- return <Tag style={{ margin: "0.4em 0", fontFamily: t.headingFontFamily }}>{children}</Tag>;
145
+ return (
146
+ <Tag id={anchorId} style={{ margin: "0.4em 0", fontFamily: t.headingFontFamily, scrollMarginTop: 16 }}>
147
+ {children}
148
+ </Tag>
149
+ );
150
+ }
151
+ case "horizontalrule":
152
+ return <hr style={{ border: "none", borderTop: `1px solid ${t.border}`, margin: "1em 0" }} />;
153
+ case "kb-image":
154
+ case "image":
155
+ return <RichImage node={node} inline={false} />;
156
+ case "table":
157
+ return (
158
+ <div style={{ overflowX: "auto", margin: "0.6em 0" }}>
159
+ <table style={{ borderCollapse: "collapse", minWidth: "100%", fontSize: 14 }}>
160
+ <tbody>
161
+ {(node.children ?? []).map((row, i) => (
162
+ <BlockNode key={i} node={row} />
163
+ ))}
164
+ </tbody>
165
+ </table>
166
+ </div>
167
+ );
168
+ case "tablerow":
169
+ return (
170
+ <tr>
171
+ {(node.children ?? []).map((cell, i) => (
172
+ <BlockNode key={i} node={cell} />
173
+ ))}
174
+ </tr>
175
+ );
176
+ case "tablecell": {
177
+ const header = typeof node.headerState === "number" && node.headerState > 0;
178
+ const Cell = header ? "th" : "td";
179
+ return (
180
+ <Cell style={{ border: `1px solid ${t.border}`, padding: "6px 10px", textAlign: "left", verticalAlign: "top" }}>
181
+ {(node.children ?? []).map((child, i) => (
182
+ <BlockNode key={i} node={child} />
183
+ ))}
184
+ </Cell>
185
+ );
66
186
  }
67
187
  case "quote":
68
188
  return (
@@ -76,7 +196,14 @@ function BlockNode({ node }: { node: LexicalNodeJson }) {
76
196
  return <ListTag style={{ margin: "0.4em 0", paddingLeft: 22 }}>{children}</ListTag>;
77
197
  }
78
198
  case "listitem":
79
- return <li>{children}</li>;
199
+ // A nested list sits inside a list item as a block, not as inline text.
200
+ return (
201
+ <li>
202
+ {(node.children ?? []).map((child, i) =>
203
+ child.type === "list" ? <BlockNode key={i} node={child} /> : <InlineNode key={i} node={child} />,
204
+ )}
205
+ </li>
206
+ );
80
207
  case "code":
81
208
  return (
82
209
  <pre
@@ -93,16 +220,20 @@ function BlockNode({ node }: { node: LexicalNodeJson }) {
93
220
  </pre>
94
221
  );
95
222
  default:
96
- // Unknown block — render inline content so nothing is lost.
223
+ // Unknown block: render inline content so nothing is lost.
97
224
  return <p style={{ margin: "0 0 0.6em" }}>{children.length ? children : <InlineNode node={node} />}</p>;
98
225
  }
99
226
  }
100
227
 
101
228
  function InlineNode({ node }: { node: LexicalNodeJson }) {
102
229
  const t = useThemeTokens();
230
+ const linkify = useContext(LinkifyContext);
103
231
 
104
232
  if (node.type === "linebreak") return <br />;
105
233
 
234
+ // An image the editor left inside a paragraph.
235
+ if (node.type === "kb-image" || node.type === "image") return <RichImage node={node} inline />;
236
+
106
237
  if (node.type === "mention") {
107
238
  const name =
108
239
  typeof node.mentionName === "string"
@@ -110,20 +241,23 @@ function InlineNode({ node }: { node: LexicalNodeJson }) {
110
241
  : typeof node.text === "string"
111
242
  ? (node.text as string).replace(/^@/, "")
112
243
  : "member";
113
- return (
114
- <span style={{ color: t.primary, fontWeight: 600 }}>
115
- @{name}
116
- </span>
117
- );
244
+ return <span style={{ color: t.primary, fontWeight: 600 }}>@{name}</span>;
118
245
  }
119
246
 
120
247
  if (node.type === "link" || node.type === "autolink") {
121
- const url = typeof node.url === "string" ? node.url : "#";
248
+ const url = safeHref(node.url);
249
+ const inner = (node.children ?? []).map((child, i) => <InlineNode key={i} node={child} />);
250
+ if (!url) return <>{inner}</>;
251
+ // An in-page anchor stays in the page; anything else opens a new tab.
252
+ const external = !url.startsWith("#");
122
253
  return (
123
- <a href={url} target="_blank" rel="noopener noreferrer" style={{ color: t.primary, textDecoration: "underline" }}>
124
- {(node.children ?? []).map((child, i) => (
125
- <InlineNode key={i} node={child} />
126
- ))}
254
+ <a
255
+ href={url}
256
+ target={external ? "_blank" : undefined}
257
+ rel={external ? "noopener noreferrer" : undefined}
258
+ style={{ color: t.primary, textDecoration: "underline" }}
259
+ >
260
+ {inner}
127
261
  </a>
128
262
  );
129
263
  }
@@ -132,6 +266,17 @@ function InlineNode({ node }: { node: LexicalNodeJson }) {
132
266
  // Lexical text format bitmask: 1 bold, 2 italic, 8 underline, 4 strikethrough, 16 code.
133
267
  const format = typeof node.format === "number" ? node.format : 0;
134
268
  let el: React.ReactNode = node.text as string;
269
+ if (linkify && /https?:\/\//.test(node.text as string)) {
270
+ el = linkifyText(node.text as string).map((segment, i) =>
271
+ segment.kind === "link" ? (
272
+ <a key={i} href={segment.url} target="_blank" rel="noopener noreferrer" style={{ color: t.primary, textDecoration: "underline" }}>
273
+ {segment.text}
274
+ </a>
275
+ ) : (
276
+ <span key={i}>{segment.text}</span>
277
+ ),
278
+ );
279
+ }
135
280
  if (format & 16)
136
281
  el = (
137
282
  <code style={{ background: `${t.text}0d`, borderRadius: 4, padding: "0 4px", fontSize: "0.9em" }}>{el}</code>