@tribe-nest/forge 3.59.0 → 3.61.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (266) hide show
  1. package/package.json +2 -1
  2. package/src/_tests/testCommunity.tsx +28 -0
  3. package/src/contexts/CartContext.tsx +34 -1
  4. package/src/contexts/CommunityContext.tsx +192 -0
  5. package/src/contexts/PublicAuthContext.tsx +15 -3
  6. package/src/data/queries/_tests/metaSignals.spec.tsx +130 -0
  7. package/src/data/queries/useAccount.ts +33 -2
  8. package/src/data/queries/useAnalytics.ts +6 -6
  9. package/src/data/queries/useCallChat.ts +2 -2
  10. package/src/data/queries/useChat.ts +52 -40
  11. package/src/data/queries/useCheckouts.ts +2 -1
  12. package/src/data/queries/useCoachingAvailability.ts +10 -1
  13. package/src/data/queries/useCommunities.ts +345 -0
  14. package/src/data/queries/useCommunity.ts +450 -102
  15. package/src/data/queries/useCommunityAgent.ts +247 -0
  16. package/src/data/queries/useCommunityCalendar.ts +150 -0
  17. package/src/data/queries/useCommunityClassroom.ts +75 -0
  18. package/src/data/queries/useCommunityMembers.ts +108 -0
  19. package/src/data/queries/useCommunityModeration.ts +327 -0
  20. package/src/data/queries/useCommunityWiki.ts +137 -0
  21. package/src/data/queries/useCourses.ts +2 -0
  22. package/src/data/queries/useEmailSignIn.ts +79 -0
  23. package/src/data/queries/useEvents.ts +6 -1
  24. package/src/data/queries/useForms.ts +5 -1
  25. package/src/data/queries/useLeadMagnet.ts +6 -2
  26. package/src/data/queries/useMembership.ts +32 -7
  27. package/src/data/queries/useMembershipCalls.ts +116 -7
  28. package/src/data/queries/useMyBookings.ts +24 -1
  29. package/src/data/queries/useMyTickets.ts +24 -1
  30. package/src/data/queries/useNotifications.ts +6 -2
  31. package/src/data/queries/useOrders.ts +2 -1
  32. package/src/data/queries/usePWA.ts +111 -35
  33. package/src/data/queries/usePostCollections.ts +28 -16
  34. package/src/data/queries/usePosts.ts +16 -14
  35. package/src/data/queries/useSiteApp.ts +130 -0
  36. package/src/data/queries/useSubscriptions.ts +28 -9
  37. package/src/i18n/de.json +891 -2
  38. package/src/i18n/en.json +891 -2
  39. package/src/index.ts +36 -7
  40. package/src/runtime/pages.spec.ts +36 -0
  41. package/src/runtime/pages.ts +52 -27
  42. package/src/server/_tests/buildWebManifest.spec.ts +1 -1
  43. package/src/server/_tests/forgeServiceWorkerScript.spec.ts +4 -4
  44. package/src/types/models.ts +11 -0
  45. package/src/ui/analytics/ForgeAnalytics.tsx +64 -34
  46. package/src/ui/analytics/PageMetaPixel.tsx +23 -8
  47. package/src/ui/analytics/_tests/ForgeAnalytics.meta.spec.tsx +129 -0
  48. package/src/ui/analytics/_tests/metaCommerceEvents.spec.tsx +159 -0
  49. package/src/ui/analytics/useMetaEvents.ts +78 -0
  50. package/src/ui/format/_tests/membershipGate.spec.ts +6 -6
  51. package/src/ui/format/membershipGate.ts +19 -15
  52. package/src/ui/headless/_tests/communityAgent.spec.tsx +519 -0
  53. package/src/ui/headless/_tests/communityCalendar.spec.tsx +195 -0
  54. package/src/ui/headless/_tests/communityClassroom.spec.tsx +120 -0
  55. package/src/ui/headless/_tests/communityGate.spec.tsx +80 -0
  56. package/src/ui/headless/_tests/communityHarness.tsx +212 -0
  57. package/src/ui/headless/_tests/communityMembers.spec.tsx +316 -0
  58. package/src/ui/headless/_tests/communityPoll.spec.tsx +389 -0
  59. package/src/ui/headless/_tests/communityProvider.spec.tsx +151 -0
  60. package/src/ui/headless/_tests/memberHomeBar.spec.ts +84 -0
  61. package/src/ui/headless/_tests/siteAppBar.spec.ts +83 -0
  62. package/src/ui/headless/_tests/siteAppHarness.ts +19 -0
  63. package/src/ui/headless/_tests/siteAppHome.spec.ts +226 -0
  64. package/src/ui/headless/_tests/siteAppPrompts.spec.ts +185 -0
  65. package/src/ui/headless/_tests/useCommunityInvite.spec.tsx +104 -0
  66. package/src/ui/headless/_tests/useCommunityJoin.spec.tsx +596 -0
  67. package/src/ui/headless/_tests/useCommunityMembership.spec.tsx +179 -0
  68. package/src/ui/headless/_tests/useCommunityModeration.spec.tsx +420 -0
  69. package/src/ui/headless/_tests/useCommunityReads.spec.tsx +295 -0
  70. package/src/ui/headless/_tests/useCommunityUpgrade.spec.tsx +157 -0
  71. package/src/ui/headless/_tests/useCommunityWiki.spec.tsx +224 -0
  72. package/src/ui/headless/_tests/useSiteApp.spec.tsx +52 -0
  73. package/src/ui/headless/_tests/wikiMediaImage.spec.tsx +132 -0
  74. package/src/ui/headless/auth/useEmailCodeSignIn.ts +186 -0
  75. package/src/ui/headless/auth/useSignupForm.ts +30 -3
  76. package/src/ui/headless/chat/useChatAttachmentUpload.ts +9 -6
  77. package/src/ui/headless/chat/useChatRoom.ts +35 -1
  78. package/src/ui/headless/communities/CommunityGate.tsx +67 -0
  79. package/src/ui/headless/communities/index.ts +96 -0
  80. package/src/ui/headless/communities/paths.ts +36 -0
  81. package/src/ui/headless/communities/useCommunity.ts +140 -0
  82. package/src/ui/headless/communities/useCommunityAbout.ts +71 -0
  83. package/src/ui/headless/communities/useCommunityCalendar.ts +156 -0
  84. package/src/ui/headless/communities/useCommunityClassroom.ts +84 -0
  85. package/src/ui/headless/communities/useCommunityHome.ts +91 -0
  86. package/src/ui/headless/communities/useCommunityInvite.ts +116 -0
  87. package/src/ui/headless/communities/useCommunityJoin.ts +494 -0
  88. package/src/ui/headless/communities/useCommunityMembership.ts +182 -0
  89. package/src/ui/headless/communities/useCommunityUpgrade.ts +213 -0
  90. package/src/ui/headless/communities/useCommunityWiki.ts +266 -0
  91. package/src/ui/headless/communities/useMyMemberships.ts +59 -0
  92. package/src/ui/headless/community/communityPoll.ts +297 -0
  93. package/src/ui/headless/community/countries.ts +123 -0
  94. package/src/ui/headless/community/index.ts +81 -1
  95. package/src/ui/headless/community/linkPreview.ts +71 -0
  96. package/src/ui/headless/community/richLexical.ts +27 -2
  97. package/src/ui/headless/community/useCommunityAgent.ts +285 -0
  98. package/src/ui/headless/community/useCommunityAttachmentUpload.ts +7 -3
  99. package/src/ui/headless/community/useCommunityMembers.ts +175 -0
  100. package/src/ui/headless/community/useCommunityModeration.ts +300 -0
  101. package/src/ui/headless/community/useCommunitySpaceFeed.ts +22 -4
  102. package/src/ui/headless/community/useCommunityThread.ts +16 -3
  103. package/src/ui/headless/community/useMentionComposer.ts +17 -9
  104. package/src/ui/headless/community/useRichMentionComposer.ts +21 -5
  105. package/src/ui/headless/downloads/index.ts +8 -0
  106. package/src/ui/headless/downloads/useDownloadPage.ts +134 -0
  107. package/src/ui/headless/index.ts +21 -4
  108. package/src/ui/headless/memberHome/index.ts +11 -1
  109. package/src/ui/headless/memberHome/memberHomeBar.ts +91 -0
  110. package/src/ui/headless/memberHome/useMemberHomeNav.ts +84 -44
  111. package/src/ui/headless/membership/_tests/membershipCheckoutRefetch.spec.tsx +8 -0
  112. package/src/ui/headless/membership/_tests/useMembershipCheckoutApplication.spec.tsx +5 -4
  113. package/src/ui/headless/membership/_tests/useMembershipCheckoutQuestionnaire.spec.tsx +1 -1
  114. package/src/ui/headless/membership/_tests/useMembershipCheckoutTrial.spec.tsx +1 -1
  115. package/src/ui/headless/membership/useMembershipCheckout.ts +89 -22
  116. package/src/ui/headless/membership/useMembershipGateNotice.ts +25 -7
  117. package/src/ui/headless/portal/index.ts +57 -0
  118. package/src/ui/headless/portal/usePortal.ts +274 -0
  119. package/src/ui/headless/portal/usePortalFiles.ts +68 -0
  120. package/src/ui/headless/portal/usePortalMessages.ts +189 -0
  121. package/src/ui/headless/portal/usePortalPreview.ts +142 -0
  122. package/src/ui/headless/posts/upsell.ts +54 -8
  123. package/src/ui/headless/posts/useMemberPostsFeed.ts +6 -5
  124. package/src/ui/headless/posts/usePostCollectionDetail.ts +7 -4
  125. package/src/ui/headless/posts/usePostCollectionsList.ts +6 -5
  126. package/src/ui/headless/posts/usePostItem.ts +6 -5
  127. package/src/ui/headless/siteApp/appBar.ts +102 -0
  128. package/src/ui/headless/siteApp/appHome.ts +344 -0
  129. package/src/ui/headless/siteApp/appPrompts.ts +229 -0
  130. package/src/ui/headless/siteApp/index.ts +58 -0
  131. package/src/ui/headless/siteApp/useAppHome.ts +210 -0
  132. package/src/ui/headless/siteApp/useAppInstallRequest.ts +16 -0
  133. package/src/ui/headless/work/index.ts +2 -0
  134. package/src/ui/headless/work/useWorkPortal.ts +37 -26
  135. package/src/ui/index.ts +28 -12
  136. package/src/ui/styled/AccountDashboard.tsx +754 -602
  137. package/src/ui/styled/AiAgentWidget.tsx +13 -1
  138. package/src/ui/styled/ChatRoom.tsx +126 -16
  139. package/src/ui/styled/Checkout.tsx +8 -0
  140. package/src/ui/styled/CheckoutConfirmation.tsx +8 -0
  141. package/src/ui/styled/CoachingConfirmation.tsx +8 -0
  142. package/src/ui/styled/CoachingDetail.tsx +2 -0
  143. package/src/ui/styled/CourseConfirmation.tsx +5 -0
  144. package/src/ui/styled/CourseDetail.tsx +2 -0
  145. package/src/ui/styled/DownloadPage.tsx +263 -0
  146. package/src/ui/styled/EventConfirmation.tsx +10 -0
  147. package/src/ui/styled/EventDetail.tsx +10 -0
  148. package/src/ui/styled/FilmLibrary.tsx +22 -9
  149. package/src/ui/styled/MembershipCheckout.tsx +37 -17
  150. package/src/ui/styled/MembershipTierCallout.tsx +20 -1
  151. package/src/ui/styled/MembershipTiers.tsx +49 -11
  152. package/src/ui/styled/MusicLinkPage.tsx +5 -5
  153. package/src/ui/styled/Paywall.tsx +1 -1
  154. package/src/ui/styled/ProductDetail.tsx +12 -0
  155. package/src/ui/styled/PwaRegistration.tsx +5 -1
  156. package/src/ui/styled/SignupForm.tsx +18 -3
  157. package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +6 -6
  158. package/src/ui/styled/_tests/AccountDashboardMemberships.spec.tsx +151 -0
  159. package/src/ui/styled/_tests/AccountDashboardRentals.spec.tsx +1 -1
  160. package/src/ui/styled/_tests/AccountNotifications.spec.tsx +135 -0
  161. package/src/ui/styled/_tests/CourseCallsSection.spec.tsx +7 -1
  162. package/src/ui/styled/_tests/DownloadPage.spec.tsx +68 -0
  163. package/src/ui/styled/_tests/MembershipCheckout.spec.tsx +54 -11
  164. package/src/ui/styled/_tests/membershipTierCallout.spec.tsx +1 -1
  165. package/src/ui/styled/_tests/membershipTiersCuratedAccess.spec.tsx +1 -1
  166. package/src/ui/styled/app/AppBar.tsx +298 -0
  167. package/src/ui/styled/app/AppHome.tsx +1139 -0
  168. package/src/ui/styled/app/AppHomeMark.tsx +66 -0
  169. package/src/ui/styled/app/AppPrompts.tsx +464 -0
  170. package/src/ui/styled/app/_tests/AppBar.spec.tsx +96 -0
  171. package/src/ui/styled/app/_tests/AppHome.spec.tsx +209 -0
  172. package/src/ui/styled/app/_tests/AppPrompts.spec.tsx +232 -0
  173. package/src/ui/styled/app/index.ts +7 -0
  174. package/src/ui/styled/chat/ChatThreadPanel.tsx +29 -4
  175. package/src/ui/styled/chat/MessageBubble.tsx +43 -6
  176. package/src/ui/styled/communities/CommunitiesList.tsx +88 -0
  177. package/src/ui/styled/communities/CommunityAbout.tsx +145 -0
  178. package/src/ui/styled/communities/CommunityCalendar.tsx +484 -0
  179. package/src/ui/styled/communities/CommunityClassroom.tsx +302 -0
  180. package/src/ui/styled/communities/CommunityHome.tsx +298 -0
  181. package/src/ui/styled/communities/CommunityJoinSheet.tsx +544 -0
  182. package/src/ui/styled/communities/CommunityMembershipMenu.tsx +249 -0
  183. package/src/ui/styled/communities/CommunityPending.tsx +49 -0
  184. package/src/ui/styled/communities/CommunityPublicWiki.tsx +75 -0
  185. package/src/ui/styled/communities/CommunityUpgradeSheet.tsx +137 -0
  186. package/src/ui/styled/communities/CommunityWiki.tsx +289 -0
  187. package/src/ui/styled/communities/MyMembershipsList.tsx +115 -0
  188. package/src/ui/styled/communities/_tests/CommunityAppHome.spec.tsx +92 -0
  189. package/src/ui/styled/communities/_tests/CommunityClassroomCalendar.spec.tsx +453 -0
  190. package/src/ui/styled/communities/_tests/CommunityMobile.spec.tsx +399 -0
  191. package/src/ui/styled/communities/_tests/CommunityPages.spec.tsx +102 -0
  192. package/src/ui/styled/communities/_tests/CommunityWiki.spec.tsx +223 -0
  193. package/src/ui/styled/communities/index.ts +19 -0
  194. package/src/ui/styled/communities/shared.tsx +177 -0
  195. package/src/ui/styled/community/CommunityAgent.tsx +286 -0
  196. package/src/ui/styled/community/CommunityComposer.tsx +266 -15
  197. package/src/ui/styled/community/CommunityFeed.tsx +50 -1
  198. package/src/ui/styled/community/CommunityLinkPreview.tsx +159 -0
  199. package/src/ui/styled/community/CommunityMemberGlobe.tsx +171 -0
  200. package/src/ui/styled/community/CommunityMemberProfile.tsx +137 -4
  201. package/src/ui/styled/community/CommunityMembers.tsx +387 -0
  202. package/src/ui/styled/community/CommunityModeration.tsx +587 -0
  203. package/src/ui/styled/community/CommunityModerationState.tsx +127 -0
  204. package/src/ui/styled/community/CommunityModeratorMenu.tsx +317 -0
  205. package/src/ui/styled/community/CommunityNotifications.tsx +52 -6
  206. package/src/ui/styled/community/CommunityPollCard.tsx +250 -0
  207. package/src/ui/styled/community/CommunityPostCard.tsx +110 -34
  208. package/src/ui/styled/community/CommunityPostDetail.tsx +157 -71
  209. package/src/ui/styled/community/CommunityRichText.tsx +177 -32
  210. package/src/ui/styled/community/CommunitySpaces.tsx +49 -14
  211. package/src/ui/styled/community/_tests/CommunityAgent.spec.tsx +571 -0
  212. package/src/ui/styled/community/_tests/CommunityComposer.spec.tsx +4 -1
  213. package/src/ui/styled/community/_tests/CommunityLinkPreview.spec.tsx +167 -0
  214. package/src/ui/styled/community/_tests/CommunityMembers.spec.tsx +137 -0
  215. package/src/ui/styled/community/_tests/CommunityModeration.spec.tsx +291 -0
  216. package/src/ui/styled/community/_tests/CommunityPoll.spec.tsx +386 -0
  217. package/src/ui/styled/community/_tests/CommunityPostDetail.spec.tsx +4 -1
  218. package/src/ui/styled/community/index.ts +37 -2
  219. package/src/ui/styled/forge-utilities.css +253 -0
  220. package/src/ui/styled/members/CallRecordingActions.tsx +182 -0
  221. package/src/ui/styled/members/CallTranscriptViewer.tsx +424 -0
  222. package/src/ui/styled/members/MemberCallsCalendar.tsx +41 -54
  223. package/src/ui/styled/members/MemberHomeLayout.tsx +327 -29
  224. package/src/ui/styled/members/MemberPostCard.tsx +9 -5
  225. package/src/ui/styled/members/MemberPostsFeed.tsx +3 -2
  226. package/src/ui/styled/members/PostCollectionDetail.tsx +18 -10
  227. package/src/ui/styled/members/PostCollectionsGrid.tsx +24 -10
  228. package/src/ui/styled/members/_tests/MemberCallsCalendar.spec.tsx +167 -8
  229. package/src/ui/styled/members/callTranscriptText.ts +67 -0
  230. package/src/ui/styled/members/index.ts +8 -0
  231. package/src/ui/styled/portal/PortalAccountSection.tsx +61 -0
  232. package/src/ui/styled/portal/PortalActivity.tsx +167 -0
  233. package/src/ui/styled/portal/PortalBilling.tsx +197 -0
  234. package/src/ui/styled/portal/PortalFiles.tsx +279 -0
  235. package/src/ui/styled/portal/PortalHome.tsx +401 -0
  236. package/src/ui/styled/portal/PortalMessages.tsx +300 -0
  237. package/src/ui/styled/portal/PortalPreviewBanner.tsx +75 -0
  238. package/src/ui/styled/portal/PortalProjects.tsx +407 -0
  239. package/src/ui/styled/portal/PortalShell.tsx +397 -0
  240. package/src/ui/styled/portal/_tests/PortalAppHome.spec.tsx +90 -0
  241. package/src/ui/styled/portal/_tests/PortalFiles.spec.tsx +159 -0
  242. package/src/ui/styled/portal/_tests/PortalHome.spec.tsx +148 -0
  243. package/src/ui/styled/portal/_tests/PortalMessages.spec.tsx +168 -0
  244. package/src/ui/styled/portal/_tests/PortalPreviewBanner.spec.tsx +132 -0
  245. package/src/ui/styled/portal/_tests/portalNav.spec.ts +123 -0
  246. package/src/ui/styled/portal/index.ts +13 -0
  247. package/src/ui/styled/portal/portalNav.ts +260 -0
  248. package/src/ui/styled/portal/shared.tsx +206 -0
  249. package/src/ui/styled/work/WorkInviteAccept.tsx +228 -168
  250. package/src/ui/styled/work/WorkPortalProjects.tsx +68 -120
  251. package/src/ui/styled/work/WorkProjectDocuments.tsx +99 -128
  252. package/src/ui/styled/work/WorkProjectInvoices.tsx +91 -86
  253. package/src/ui/styled/work/WorkProjectReport.tsx +11 -17
  254. package/src/ui/styled/work/WorkReportBody.tsx +223 -248
  255. package/src/ui/styled/work/WorkTaskAttachments.tsx +23 -29
  256. package/src/ui/styled/work/WorkTaskComments.tsx +75 -102
  257. package/src/ui/styled/work/WorkTaskDetail.tsx +48 -59
  258. package/src/ui/styled/work/WorkTokenReport.tsx +8 -16
  259. package/src/ui/styled/work/index.ts +1 -1
  260. package/src/utils/_tests/metaEvents.spec.ts +264 -0
  261. package/src/utils/_tests/safeRedirect.spec.ts +2 -2
  262. package/src/utils/landing.ts +19 -6
  263. package/src/utils/metaEvents.ts +302 -0
  264. package/src/ui/headless/membership/MembershipGate.tsx +0 -44
  265. package/src/ui/styled/_tests/AccountDashboardCommunity.spec.tsx +0 -149
  266. package/src/ui/styled/_tests/AccountDashboardTrial.spec.tsx +0 -105
@@ -0,0 +1,286 @@
1
+ import { Bot, Sparkles, ThumbsDown, ThumbsUp, X } from "lucide-react";
2
+ import { useForgeT } from "../../../i18n";
3
+ import { useThemeTokens } from "../../theme/ForgeThemeProvider";
4
+ import {
5
+ linkifyText,
6
+ useCommunityAgentAnswerFeedback,
7
+ useCommunityCatchUp,
8
+ } from "../../headless/community/useCommunityAgent";
9
+ import type { CommunityAgentFeedbackEntity } from "../../../data/queries/useCommunityAgent";
10
+
11
+ /** What an agent is drawn from: its name and avatar. */
12
+ export interface CommunityAgentIdentity {
13
+ name: string;
14
+ avatarUrl?: string | null;
15
+ }
16
+
17
+ /**
18
+ * The "AI" chip. Shown beside the community AI's name everywhere it appears,
19
+ * so it is never mistaken for a member or for the creator.
20
+ */
21
+ export function CommunityAgentBadge() {
22
+ const t = useThemeTokens();
23
+ const tr = useForgeT();
24
+ return (
25
+ <span
26
+ data-testid="community-agent-badge"
27
+ title={tr("forge.community_agent.badge_title")}
28
+ style={{
29
+ background: t.primary,
30
+ color: t.textPrimary,
31
+ borderRadius: 999,
32
+ fontSize: 10,
33
+ fontWeight: 700,
34
+ padding: "1px 6px",
35
+ letterSpacing: 0.4,
36
+ whiteSpace: "nowrap",
37
+ flexShrink: 0,
38
+ }}
39
+ >
40
+ {tr("forge.community_agent.badge")}
41
+ </span>
42
+ );
43
+ }
44
+
45
+ export interface CommunityAgentAvatarProps {
46
+ agent: CommunityAgentIdentity;
47
+ size?: number;
48
+ }
49
+
50
+ /** The agent's avatar, or a neutral AI glyph when the creator set none. */
51
+ export function CommunityAgentAvatar({ agent, size = 36 }: CommunityAgentAvatarProps) {
52
+ const t = useThemeTokens();
53
+ const style: React.CSSProperties = { width: size, height: size, borderRadius: "50%", flexShrink: 0, objectFit: "cover" };
54
+ if (agent.avatarUrl) return <img src={agent.avatarUrl} alt={agent.name} style={style} />;
55
+ return (
56
+ <div
57
+ role="img"
58
+ aria-label={agent.name}
59
+ data-testid="community-agent-glyph"
60
+ style={{
61
+ ...style,
62
+ display: "flex",
63
+ alignItems: "center",
64
+ justifyContent: "center",
65
+ background: `${t.primary}22`,
66
+ color: t.primary,
67
+ }}
68
+ >
69
+ <Bot size={Math.max(12, Math.round(size * 0.55))} aria-hidden />
70
+ </div>
71
+ );
72
+ }
73
+
74
+ export interface CommunityAgentNameProps {
75
+ agent: CommunityAgentIdentity;
76
+ fontSize?: number;
77
+ fontWeight?: number;
78
+ }
79
+
80
+ /** The agent's name with its "AI" badge, in place of a member's name. */
81
+ export function CommunityAgentName({ agent, fontSize = 14, fontWeight = 600 }: CommunityAgentNameProps) {
82
+ const t = useThemeTokens();
83
+ return (
84
+ <span style={{ display: "inline-flex", alignItems: "center", gap: 6, minWidth: 0 }}>
85
+ <span
86
+ style={{
87
+ color: t.text,
88
+ fontWeight,
89
+ fontSize,
90
+ whiteSpace: "nowrap",
91
+ overflow: "hidden",
92
+ textOverflow: "ellipsis",
93
+ }}
94
+ >
95
+ {agent.name}
96
+ </span>
97
+ <CommunityAgentBadge />
98
+ </span>
99
+ );
100
+ }
101
+
102
+ /**
103
+ * Plain text with its http(s) addresses made clickable, line breaks kept. For
104
+ * the agent's answers, which end with a "Sources:" list of addresses.
105
+ */
106
+ export function CommunityLinkifiedText({ text }: { text: string }) {
107
+ const t = useThemeTokens();
108
+ return (
109
+ <span style={{ whiteSpace: "pre-line", wordBreak: "break-word" }}>
110
+ {linkifyText(text).map((segment, i) =>
111
+ segment.kind === "link" ? (
112
+ <a
113
+ key={i}
114
+ href={segment.url}
115
+ target="_blank"
116
+ rel="noopener noreferrer"
117
+ style={{ color: "inherit", textDecoration: "underline", textDecorationColor: t.primary }}
118
+ >
119
+ {segment.text}
120
+ </a>
121
+ ) : (
122
+ <span key={i}>{segment.text}</span>
123
+ ),
124
+ )}
125
+ </span>
126
+ );
127
+ }
128
+
129
+ /** "<name> is thinking..." while the agent writes an answer. */
130
+ export function CommunityAgentThinking({ name }: { name?: string | null }) {
131
+ const t = useThemeTokens();
132
+ const tr = useForgeT();
133
+ return (
134
+ <div
135
+ role="status"
136
+ data-testid="community-agent-thinking"
137
+ style={{ display: "inline-flex", alignItems: "center", gap: 6, fontSize: 12, color: t.muted }}
138
+ >
139
+ <Sparkles size={13} aria-hidden style={{ color: t.primary }} />
140
+ {name ? tr("forge.community_agent.thinking", { agent_name: name }) : tr("forge.community_agent.thinking_unnamed")}
141
+ </div>
142
+ );
143
+ }
144
+
145
+ export interface CommunityAgentFeedbackProps {
146
+ entityType: CommunityAgentFeedbackEntity;
147
+ entityId: string;
148
+ /** The member's saved rating, from `useCommunityAgentAnswerRatings`. */
149
+ saved?: 1 | -1 | null;
150
+ }
151
+
152
+ /**
153
+ * Thumbs up / down on one AI answer, for the member who asked (render it only
154
+ * where `useCommunityAgentAnswerRatings` says they may rate). Starts from the
155
+ * saved rating; optimistic; a second press on the active thumb clears it. A
156
+ * 404 hides it without a word.
157
+ */
158
+ export function CommunityAgentFeedback({ entityType, entityId, saved = null }: CommunityAgentFeedbackProps) {
159
+ const t = useThemeTokens();
160
+ const tr = useForgeT();
161
+ const feedback = useCommunityAgentAnswerFeedback(entityType, entityId, saved);
162
+ if (feedback.hidden) return null;
163
+
164
+ const thumb = (value: 1 | -1, label: string, icon: React.ReactNode) => {
165
+ const active = feedback.value === value;
166
+ return (
167
+ <button
168
+ type="button"
169
+ onClick={() => feedback.rate(value)}
170
+ aria-label={label}
171
+ aria-pressed={active}
172
+ title={label}
173
+ style={{
174
+ display: "inline-flex",
175
+ alignItems: "center",
176
+ background: active ? `${t.primary}1a` : "none",
177
+ border: "none",
178
+ borderRadius: t.cornerRadius,
179
+ cursor: "pointer",
180
+ color: active ? t.primary : t.muted,
181
+ padding: "2px 4px",
182
+ }}
183
+ >
184
+ {icon}
185
+ </button>
186
+ );
187
+ };
188
+
189
+ return (
190
+ <span data-testid="community-agent-feedback" style={{ display: "inline-flex", alignItems: "center", gap: 2 }}>
191
+ {thumb(1, tr("forge.community_agent.feedback_up"), <ThumbsUp size={13} aria-hidden />)}
192
+ {thumb(-1, tr("forge.community_agent.feedback_down"), <ThumbsDown size={13} aria-hidden />)}
193
+ </span>
194
+ );
195
+ }
196
+
197
+ export interface CommunityCatchMeUpProps {
198
+ postId: string;
199
+ agent: CommunityAgentIdentity;
200
+ }
201
+
202
+ /**
203
+ * "Catch me up" on a long thread. The summary opens in a private panel, marked
204
+ * as written by the AI, that only this member sees; nothing is posted. A
205
+ * reached daily limit or an unavailable AI is said plainly.
206
+ */
207
+ export function CommunityCatchMeUp({ postId, agent }: CommunityCatchMeUpProps) {
208
+ const t = useThemeTokens();
209
+ const tr = useForgeT();
210
+ const catchUp = useCommunityCatchUp(postId);
211
+ const border = `1px solid ${t.border}`;
212
+
213
+ const errorCopy =
214
+ catchUp.error === "daily_limit"
215
+ ? tr("forge.community_agent.catch_up_daily_limit")
216
+ : catchUp.error === "unavailable"
217
+ ? tr("forge.community_agent.catch_up_unavailable")
218
+ : catchUp.error
219
+ ? tr("forge.community_agent.catch_up_failed")
220
+ : null;
221
+
222
+ if (catchUp.summary) {
223
+ return (
224
+ <section
225
+ data-testid="community-catch-me-up-panel"
226
+ aria-label={tr("forge.community_agent.catch_up_panel_label", { agent_name: catchUp.summary.agentName })}
227
+ style={{ border, borderRadius: t.cornerRadius, background: `${t.primary}0d`, padding: 14 }}
228
+ >
229
+ <header style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 8 }}>
230
+ <CommunityAgentAvatar agent={agent} size={24} />
231
+ <span style={{ flex: 1, minWidth: 0, display: "flex", alignItems: "center", gap: 6, flexWrap: "wrap" }}>
232
+ <CommunityAgentName agent={{ ...agent, name: catchUp.summary.agentName || agent.name }} fontSize={13} />
233
+ <span style={{ fontSize: 12, color: t.muted }}>{tr("forge.community_agent.catch_up_private")}</span>
234
+ </span>
235
+ <button
236
+ type="button"
237
+ onClick={catchUp.dismiss}
238
+ aria-label={tr("forge.community_agent.catch_up_dismiss")}
239
+ style={{ background: "none", border: "none", cursor: "pointer", color: t.muted, padding: 2 }}
240
+ >
241
+ <X size={15} aria-hidden />
242
+ </button>
243
+ </header>
244
+ <div style={{ color: t.text, fontSize: 14, lineHeight: 1.55 }}>
245
+ <CommunityLinkifiedText text={catchUp.summary.summary} />
246
+ </div>
247
+ </section>
248
+ );
249
+ }
250
+
251
+ return (
252
+ <div style={{ display: "flex", flexDirection: "column", gap: 6, alignItems: "flex-start" }}>
253
+ <button
254
+ type="button"
255
+ onClick={catchUp.ask}
256
+ disabled={catchUp.isPending}
257
+ style={{
258
+ display: "inline-flex",
259
+ alignItems: "center",
260
+ gap: 6,
261
+ background: "none",
262
+ border,
263
+ borderRadius: t.cornerRadius,
264
+ padding: "6px 12px",
265
+ cursor: catchUp.isPending ? "wait" : "pointer",
266
+ color: t.text,
267
+ fontSize: 13,
268
+ fontWeight: 600,
269
+ opacity: catchUp.isPending ? 0.7 : 1,
270
+ }}
271
+ >
272
+ <Sparkles size={14} aria-hidden style={{ color: t.primary }} />
273
+ {catchUp.isPending ? tr("forge.community_agent.catch_up_pending") : tr("forge.community_agent.catch_up")}
274
+ </button>
275
+ {errorCopy && (
276
+ <div
277
+ role="alert"
278
+ data-testid="community-catch-me-up-error"
279
+ style={{ fontSize: 13, color: t.text, borderLeft: `3px solid ${t.primary}`, paddingLeft: 8 }}
280
+ >
281
+ {errorCopy}
282
+ </div>
283
+ )}
284
+ </div>
285
+ );
286
+ }
@@ -1,9 +1,15 @@
1
1
  import { useEffect, useId, useRef, useState } from "react";
2
- import { Bold, Italic, Underline, List, ListOrdered, Link as LinkIcon, PenLine } from "lucide-react";
2
+ import { BarChart3, Bold, Italic, Underline, List, ListOrdered, Link as LinkIcon, PenLine, Plus, X } from "lucide-react";
3
+ import { useForgeT } from "../../../i18n";
3
4
  import { useThemeTokens } from "../../theme/ForgeThemeProvider";
4
5
  import { formatBytes } from "../../headless/chat/attachmentKind";
5
6
  import { useCommunityAttachmentUpload } from "../../headless/community/useCommunityAttachmentUpload";
6
7
  import { useRichMentionComposer, type RichCommand } from "../../headless/community/useRichMentionComposer";
8
+ import {
9
+ COMMUNITY_POLL_LIMITS,
10
+ buildPollPayload,
11
+ useCommunityPollDraft,
12
+ } from "../../headless/community/communityPoll";
7
13
  // Forge's own `?` disclosure. It is named for the call screen it was first built
8
14
  // for, but it is a plain button + panel over the theme tokens with no media
9
15
  // dependency, so every styled surface uses it rather than growing a second one.
@@ -11,18 +17,50 @@ import { useRichMentionComposer, type RichCommand } from "../../headless/communi
11
17
  // is not a Forge dependency and would not resolve on a real creator site.
12
18
  import { CallHelpHint } from "../../media/CallHelpHint";
13
19
  import { MemberAvatar } from "./MemberAvatar";
14
- import type { CommunityAttachmentInput } from "../../../data/queries/useCommunity";
20
+ import { CommunityAgentAvatar, CommunityAgentName } from "./CommunityAgent";
21
+ import type {
22
+ CommunityAttachmentInput,
23
+ CommunityPollInput,
24
+ CommunityPollResultsVisibility,
25
+ } from "../../../data/queries/useCommunity";
15
26
 
16
27
  export interface CommunityComposerSubmit {
17
28
  title?: string;
18
29
  body: unknown;
19
30
  attachments: CommunityAttachmentInput[];
20
31
  mentionedAccountIds: string[];
32
+ /** Present when the member attached a poll. `title` is then its question. */
33
+ poll?: CommunityPollInput;
21
34
  }
22
35
 
36
+ const POLL_ERROR_KEY: Record<string, string> = {
37
+ question_required: "forge.community_composer.poll_question_required",
38
+ question_too_long: "forge.community_composer.poll_question_too_long",
39
+ option_empty: "forge.community_composer.poll_option_empty",
40
+ option_too_long: "forge.community_composer.poll_option_too_long",
41
+ option_duplicate: "forge.community_composer.poll_option_duplicate",
42
+ options_too_few: "forge.community_composer.poll_options_too_few",
43
+ options_too_many: "forge.community_composer.poll_options_too_many",
44
+ closes_invalid: "forge.community_composer.poll_closes_invalid",
45
+ closes_in_past: "forge.community_composer.poll_closes_in_past",
46
+ closes_required: "forge.community_composer.poll_closes_required",
47
+ visibility_invalid: "forge.community_composer.poll_visibility_invalid",
48
+ };
49
+
50
+ const VISIBILITY_KEY: Record<CommunityPollResultsVisibility, string> = {
51
+ always: "forge.community_composer.poll_visibility_always",
52
+ after_vote: "forge.community_composer.poll_visibility_after_vote",
53
+ after_close: "forge.community_composer.poll_visibility_after_close",
54
+ };
55
+
23
56
  export interface CommunityComposerProps {
24
57
  /** Show the title input (forum spaces). */
25
58
  withTitle?: boolean;
59
+ /**
60
+ * Offer a Poll toggle (the space feed's post box). The poll's question is
61
+ * the post title, so turning it on shows the title input whatever `withTitle` says.
62
+ */
63
+ allowPoll?: boolean;
26
64
  placeholder?: string;
27
65
  submitLabel?: string;
28
66
  autoFocus?: boolean;
@@ -72,6 +110,7 @@ export interface CommunityComposerProps {
72
110
  */
73
111
  export function CommunityComposer({
74
112
  withTitle = false,
113
+ allowPoll = false,
75
114
  placeholder = "Write something…",
76
115
  submitLabel = "Post",
77
116
  autoFocus = false,
@@ -84,7 +123,13 @@ export function CommunityComposer({
84
123
  onCancel,
85
124
  }: CommunityComposerProps) {
86
125
  const t = useThemeTokens();
126
+ const tr = useForgeT();
87
127
  const composer = useRichMentionComposer();
128
+ const poll = useCommunityPollDraft();
129
+ const [pollOn, setPollOn] = useState(false);
130
+ const withPoll = allowPoll && pollOn;
131
+ const showTitle = withTitle || withPoll;
132
+ const pollFieldId = useId();
88
133
  const upload = useCommunityAttachmentUpload();
89
134
  const [title, setTitle] = useState("");
90
135
  const [sending, setSending] = useState(false);
@@ -105,15 +150,18 @@ export function CommunityComposer({
105
150
  const [confirmingDiscard, setConfirmingDiscard] = useState(false);
106
151
 
107
152
  const border = `1px solid ${t.border}`;
108
- const canSubmit =
109
- (!composer.isEmpty || upload.hasPending) && !sending && (!withTitle || title.trim().length > 0);
153
+ // A poll may go out with no body: the question and options are the post. Its
154
+ // own checks run on submit, so the button stays live and a click shows why.
155
+ const canSubmit = withPoll
156
+ ? !sending
157
+ : (!composer.isEmpty || upload.hasPending) && !sending && (!withTitle || title.trim().length > 0);
110
158
  /** Is there work in here that closing would otherwise have thrown away? */
111
159
  const hasDraft = !composer.isEmpty || upload.hasPending || title.trim().length > 0;
112
160
 
113
161
  /** Put the caret where typing should carry on: the title first on a forum
114
162
  * post, otherwise the end of whatever is already written. */
115
163
  const focusEditor = () => {
116
- if (withTitle && titleRef.current) {
164
+ if (showTitle && titleRef.current) {
117
165
  titleRef.current.focus();
118
166
  return;
119
167
  }
@@ -142,25 +190,37 @@ export function CommunityComposer({
142
190
  composer.reset();
143
191
  setTitle("");
144
192
  upload.clear();
193
+ poll.reset();
194
+ setPollOn(false);
145
195
  setError(null);
146
196
  setConfirmingDiscard(false);
147
197
  };
148
198
 
199
+ const changeTitle = (value: string) => {
200
+ setTitle(value);
201
+ poll.setQuestion(value);
202
+ };
203
+
149
204
  const submit = async () => {
150
205
  if (!canSubmit) return;
206
+ if (withPoll && !poll.attempt()) return;
151
207
  setSending(true);
152
208
  setError(null);
153
209
  try {
154
210
  const attachments = upload.hasPending ? await upload.uploadAll() : [];
211
+ const pollPayload = withPoll ? buildPollPayload(poll.draft) : null;
155
212
  await onSubmit({
156
- title: withTitle ? title.trim() : undefined,
213
+ title: pollPayload ? pollPayload.title : withTitle ? title.trim() : undefined,
157
214
  body: composer.getBody(),
158
215
  attachments,
159
216
  mentionedAccountIds: composer.getMentionedAccountIds(),
217
+ ...(pollPayload ? { poll: pollPayload.poll } : {}),
160
218
  });
161
219
  composer.reset();
162
220
  setTitle("");
163
221
  upload.clear();
222
+ poll.reset();
223
+ setPollOn(false);
164
224
  // Posted, so the box has served its purpose and folds away. There is no
165
225
  // draft left to lose: reset() ran a line above.
166
226
  if (collapsible) setOpen(false);
@@ -285,13 +345,15 @@ export function CommunityComposer({
285
345
  id={bodyId}
286
346
  style={{ display: isOpen ? "flex" : "none", flexDirection: "column", gap: 8 }}
287
347
  >
288
- {withTitle && (
348
+ {showTitle && (
289
349
  <input
290
350
  ref={titleRef}
291
351
  value={title}
292
- onChange={(e) => setTitle(e.target.value)}
293
- placeholder="Title"
294
- maxLength={200}
352
+ onChange={(e) => changeTitle(e.target.value)}
353
+ placeholder={withPoll ? tr("forge.community_composer.poll_question_placeholder") : "Title"}
354
+ aria-label={withPoll ? tr("forge.community_composer.poll_question_label") : undefined}
355
+ aria-invalid={withPoll && poll.showErrors && !!poll.errors.question}
356
+ maxLength={COMMUNITY_POLL_LIMITS.questionMaxLength}
295
357
  style={{
296
358
  padding: "8px 10px",
297
359
  borderRadius: t.cornerRadius,
@@ -302,6 +364,11 @@ export function CommunityComposer({
302
364
  }}
303
365
  />
304
366
  )}
367
+ {withPoll && poll.showErrors && poll.errors.question && (
368
+ <div role="alert" style={{ fontSize: 12, color: "#dc2626" }}>
369
+ {tr(POLL_ERROR_KEY[poll.errors.question])}
370
+ </div>
371
+ )}
305
372
 
306
373
  {/* Formatting toolbar */}
307
374
  <div style={{ display: "flex", alignItems: "center", gap: 4, flexWrap: "wrap" }}>
@@ -358,7 +425,8 @@ export function CommunityComposer({
358
425
  color: ${t.muted};
359
426
  pointer-events: none;
360
427
  }
361
- .forge-community-composer-editable [data-account-id] {
428
+ .forge-community-composer-editable [data-account-id],
429
+ .forge-community-composer-editable [data-agent-id] {
362
430
  color: ${t.primary};
363
431
  font-weight: 600;
364
432
  background: ${t.primary}1a;
@@ -386,7 +454,8 @@ export function CommunityComposer({
386
454
  >
387
455
  {composer.suggestions.map((s) => (
388
456
  <button
389
- key={s.accountId}
457
+ key={s.agentId ? `agent:${s.agentId}` : s.accountId}
458
+ data-testid={s.agentId ? "community-mention-agent" : "community-mention-member"}
390
459
  type="button"
391
460
  onMouseDown={(e) => {
392
461
  // mousedown so the editor keeps focus.
@@ -407,14 +476,173 @@ export function CommunityComposer({
407
476
  fontSize: 14,
408
477
  }}
409
478
  >
410
- <MemberAvatar member={{ displayName: s.name, avatarUrl: s.avatarUrl }} size={24} />
411
- <span style={{ fontWeight: 500 }}>{s.name}</span>
479
+ {s.agentId ? (
480
+ <>
481
+ <CommunityAgentAvatar agent={s} size={24} />
482
+ <CommunityAgentName agent={s} fontWeight={500} />
483
+ </>
484
+ ) : (
485
+ <>
486
+ <MemberAvatar member={{ displayName: s.name, avatarUrl: s.avatarUrl }} size={24} />
487
+ <span style={{ fontWeight: 500 }}>{s.name}</span>
488
+ </>
489
+ )}
412
490
  </button>
413
491
  ))}
414
492
  </div>
415
493
  )}
416
494
  </div>
417
495
 
496
+ {withPoll && (
497
+ <fieldset
498
+ data-testid="community-composer-poll"
499
+ style={{ border, borderRadius: t.cornerRadius, padding: 10, margin: 0, minWidth: 0 }}
500
+ >
501
+ <legend style={{ padding: "0 4px", fontSize: 13, fontWeight: 600, color: t.text }}>
502
+ {tr("forge.community_composer.poll_options_legend")}
503
+ </legend>
504
+ <div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
505
+ {poll.draft.options.map((option, index) => {
506
+ const optionError = poll.showErrors ? poll.errors.options[index] : null;
507
+ const errorId = `${pollFieldId}-option-${index}-error`;
508
+ return (
509
+ <div key={index} style={{ display: "flex", flexDirection: "column", gap: 2 }}>
510
+ <div style={{ display: "flex", alignItems: "center", gap: 6 }}>
511
+ <input
512
+ value={option}
513
+ onChange={(e) => poll.setOption(index, e.target.value)}
514
+ placeholder={tr("forge.community_composer.poll_option_placeholder", { number: index + 1 })}
515
+ aria-label={tr("forge.community_composer.poll_option_label", { number: index + 1 })}
516
+ aria-invalid={!!optionError}
517
+ aria-describedby={optionError ? errorId : undefined}
518
+ maxLength={COMMUNITY_POLL_LIMITS.optionMaxLength}
519
+ style={{
520
+ flex: 1,
521
+ minWidth: 0,
522
+ padding: "6px 10px",
523
+ borderRadius: t.cornerRadius,
524
+ border: `1px solid ${optionError ? "#dc2626" : t.border}`,
525
+ background: t.background,
526
+ color: t.text,
527
+ fontSize: 14,
528
+ }}
529
+ />
530
+ <button
531
+ type="button"
532
+ onClick={() => poll.removeOption(index)}
533
+ disabled={!poll.canRemoveOption}
534
+ aria-label={tr("forge.community_composer.poll_remove_option", { number: index + 1 })}
535
+ style={{
536
+ ...ghostButton,
537
+ padding: "4px 8px",
538
+ display: "inline-flex",
539
+ alignItems: "center",
540
+ cursor: poll.canRemoveOption ? "pointer" : "not-allowed",
541
+ opacity: poll.canRemoveOption ? 1 : 0.4,
542
+ }}
543
+ >
544
+ <X size={14} aria-hidden />
545
+ </button>
546
+ </div>
547
+ {optionError && (
548
+ <span id={errorId} role="alert" style={{ fontSize: 12, color: "#dc2626" }}>
549
+ {tr(POLL_ERROR_KEY[optionError])}
550
+ </span>
551
+ )}
552
+ </div>
553
+ );
554
+ })}
555
+ {poll.showErrors && poll.errors.optionsList && (
556
+ <span role="alert" style={{ fontSize: 12, color: "#dc2626" }}>
557
+ {tr(POLL_ERROR_KEY[poll.errors.optionsList])}
558
+ </span>
559
+ )}
560
+ <button
561
+ type="button"
562
+ onClick={poll.addOption}
563
+ disabled={!poll.canAddOption}
564
+ style={{
565
+ ...ghostButton,
566
+ alignSelf: "flex-start",
567
+ display: "inline-flex",
568
+ alignItems: "center",
569
+ gap: 4,
570
+ cursor: poll.canAddOption ? "pointer" : "not-allowed",
571
+ opacity: poll.canAddOption ? 1 : 0.5,
572
+ }}
573
+ >
574
+ <Plus size={14} aria-hidden />
575
+ {tr("forge.community_composer.poll_add_option")}
576
+ </button>
577
+ </div>
578
+
579
+ <label
580
+ style={{ display: "flex", alignItems: "center", gap: 8, marginTop: 10, fontSize: 13, color: t.text }}
581
+ >
582
+ <input
583
+ type="checkbox"
584
+ role="switch"
585
+ checked={poll.draft.multipleChoice}
586
+ onChange={(e) => poll.setMultipleChoice(e.target.checked)}
587
+ style={{ accentColor: t.primary, margin: 0 }}
588
+ />
589
+ {tr("forge.community_composer.poll_multiple_choice")}
590
+ </label>
591
+
592
+ <div style={{ display: "flex", flexWrap: "wrap", gap: 10, marginTop: 10 }}>
593
+ <label style={{ display: "flex", flexDirection: "column", gap: 4, fontSize: 13, color: t.text }}>
594
+ {tr("forge.community_composer.poll_closes_label")}
595
+ <input
596
+ type="datetime-local"
597
+ value={poll.draft.closesAt}
598
+ onChange={(e) => poll.setClosesAt(e.target.value)}
599
+ aria-invalid={poll.showErrors && !!poll.errors.closesAt}
600
+ style={{
601
+ padding: "6px 8px",
602
+ borderRadius: t.cornerRadius,
603
+ border: `1px solid ${poll.showErrors && poll.errors.closesAt ? "#dc2626" : t.border}`,
604
+ background: t.background,
605
+ color: t.text,
606
+ fontSize: 13,
607
+ }}
608
+ />
609
+ </label>
610
+ <div style={{ display: "flex", flexDirection: "column", gap: 4, fontSize: 13, color: t.text }}>
611
+ <span style={{ display: "inline-flex", alignItems: "center", gap: 4 }}>
612
+ <label htmlFor={`${pollFieldId}-visibility`}>{tr("forge.community_composer.poll_visibility_label")}</label>
613
+ <CallHelpHint label={tr("forge.community_composer.poll_visibility_help_label")}>
614
+ {tr("forge.community_composer.poll_visibility_help")}
615
+ </CallHelpHint>
616
+ </span>
617
+ <select
618
+ id={`${pollFieldId}-visibility`}
619
+ value={poll.draft.resultsVisibility}
620
+ onChange={(e) => poll.setResultsVisibility(e.target.value as CommunityPollResultsVisibility)}
621
+ style={{
622
+ padding: "6px 8px",
623
+ borderRadius: t.cornerRadius,
624
+ border,
625
+ background: t.background,
626
+ color: t.text,
627
+ fontSize: 13,
628
+ }}
629
+ >
630
+ {(Object.keys(VISIBILITY_KEY) as CommunityPollResultsVisibility[]).map((value) => (
631
+ <option key={value} value={value}>
632
+ {tr(VISIBILITY_KEY[value])}
633
+ </option>
634
+ ))}
635
+ </select>
636
+ </div>
637
+ </div>
638
+ {poll.showErrors && poll.errors.closesAt && (
639
+ <div role="alert" style={{ fontSize: 12, color: "#dc2626", marginTop: 4 }}>
640
+ {tr(POLL_ERROR_KEY[poll.errors.closesAt])}
641
+ </div>
642
+ )}
643
+ </fieldset>
644
+ )}
645
+
418
646
  {upload.error && <div style={{ fontSize: 12, color: "#dc2626" }}>{upload.error}</div>}
419
647
  {error && <div style={{ fontSize: 12, color: "#dc2626" }}>{error}</div>}
420
648
 
@@ -485,7 +713,30 @@ export function CommunityComposer({
485
713
  >
486
714
  📎
487
715
  </button>
488
- <span style={{ flex: 1, fontSize: 11, color: t.muted }}>Use @ to mention members</span>
716
+ {allowPoll && (
717
+ <button
718
+ type="button"
719
+ onClick={() => setPollOn((on) => !on)}
720
+ aria-pressed={pollOn}
721
+ style={{
722
+ ...ghostButton,
723
+ padding: "4px 10px",
724
+ display: "inline-flex",
725
+ alignItems: "center",
726
+ gap: 4,
727
+ background: pollOn ? `${t.primary}1a` : "none",
728
+ color: pollOn ? t.primary : t.text,
729
+ }}
730
+ >
731
+ <BarChart3 size={14} aria-hidden />
732
+ {tr("forge.community_composer.poll_toggle")}
733
+ </button>
734
+ )}
735
+ <span style={{ flex: 1, fontSize: 11, color: t.muted }}>
736
+ {composer.agent
737
+ ? tr("forge.community_composer.mention_hint_agent", { agent_name: composer.agent.name })
738
+ : tr("forge.community_composer.mention_hint")}
739
+ </span>
489
740
  {collapsible && (
490
741
  <>
491
742
  <CallHelpHint label="What happens to what I have typed?">