@tribe-nest/forge 1.13.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 (255) hide show
  1. package/package.json +42 -0
  2. package/src/client/createForgeClient.ts +54 -0
  3. package/src/content/ForgeContentProvider.tsx +118 -0
  4. package/src/content/ForgeEditBridge.tsx +134 -0
  5. package/src/content/editFlag.ts +23 -0
  6. package/src/content/index.ts +28 -0
  7. package/src/content/resolve.ts +67 -0
  8. package/src/content/types.ts +97 -0
  9. package/src/content/useField.ts +64 -0
  10. package/src/contexts/AppAuthContext.tsx +185 -0
  11. package/src/contexts/AudioPlayerContext.tsx +389 -0
  12. package/src/contexts/CartContext.tsx +131 -0
  13. package/src/contexts/PublicAuthContext.tsx +333 -0
  14. package/src/contexts/useAppAdminGuard.ts +56 -0
  15. package/src/css.d.ts +5 -0
  16. package/src/data/collectionParams.ts +57 -0
  17. package/src/data/queries/useAccount.ts +71 -0
  18. package/src/data/queries/useAccountSettings.ts +82 -0
  19. package/src/data/queries/useAnalytics.ts +86 -0
  20. package/src/data/queries/useAuthActions.ts +28 -0
  21. package/src/data/queries/useBlog.ts +103 -0
  22. package/src/data/queries/useBlogComments.ts +96 -0
  23. package/src/data/queries/useBroadcasts.ts +31 -0
  24. package/src/data/queries/useCertificates.ts +58 -0
  25. package/src/data/queries/useChat.ts +267 -0
  26. package/src/data/queries/useCoachingAvailability.ts +69 -0
  27. package/src/data/queries/useCoachingProducts.ts +67 -0
  28. package/src/data/queries/useCohortPage.ts +17 -0
  29. package/src/data/queries/useCollections.ts +211 -0
  30. package/src/data/queries/useCommunity.ts +794 -0
  31. package/src/data/queries/useCoupons.ts +20 -0
  32. package/src/data/queries/useCourseAccess.ts +50 -0
  33. package/src/data/queries/useCourses.ts +91 -0
  34. package/src/data/queries/useCurrencies.ts +33 -0
  35. package/src/data/queries/useDocuments.ts +208 -0
  36. package/src/data/queries/useDonations.ts +29 -0
  37. package/src/data/queries/useEngagement.ts +115 -0
  38. package/src/data/queries/useEvents.ts +69 -0
  39. package/src/data/queries/useFinalize.ts +109 -0
  40. package/src/data/queries/useForms.ts +31 -0
  41. package/src/data/queries/useInvoice.ts +65 -0
  42. package/src/data/queries/useLeadMagnet.ts +96 -0
  43. package/src/data/queries/useMembership.ts +20 -0
  44. package/src/data/queries/useNotifications.ts +267 -0
  45. package/src/data/queries/useOffers.ts +56 -0
  46. package/src/data/queries/useOrders.ts +71 -0
  47. package/src/data/queries/usePWA.ts +101 -0
  48. package/src/data/queries/usePageActions.ts +36 -0
  49. package/src/data/queries/usePaymentFlow.ts +80 -0
  50. package/src/data/queries/usePaymentLink.ts +47 -0
  51. package/src/data/queries/usePodcast.ts +85 -0
  52. package/src/data/queries/usePostCollections.ts +48 -0
  53. package/src/data/queries/usePosts.ts +94 -0
  54. package/src/data/queries/useProducts.ts +108 -0
  55. package/src/data/queries/useReplay.ts +19 -0
  56. package/src/data/queries/useReviews.ts +122 -0
  57. package/src/data/queries/useShipping.ts +109 -0
  58. package/src/data/queries/useSubscriptions.ts +66 -0
  59. package/src/data/queries/useWebsite.ts +128 -0
  60. package/src/data/queries/useWebsiteAgent.ts +100 -0
  61. package/src/index.ts +98 -0
  62. package/src/provider/ForgeAppProvider.tsx +149 -0
  63. package/src/provider/ForgeProvider.tsx +91 -0
  64. package/src/provider/SiteConfigProvider.tsx +27 -0
  65. package/src/server/_tests/buildPwaHead.spec.ts +182 -0
  66. package/src/server/_tests/buildWebManifest.spec.ts +313 -0
  67. package/src/server/_tests/forgeServiceWorkerScript.spec.ts +251 -0
  68. package/src/server/index.ts +260 -0
  69. package/src/server/jobs.ts +104 -0
  70. package/src/server/pwa.ts +332 -0
  71. package/src/types/index.ts +74 -0
  72. package/src/types/models.ts +1313 -0
  73. package/src/ui/analytics/ForgeAnalytics.tsx +208 -0
  74. package/src/ui/analytics/PageMetaPixel.tsx +92 -0
  75. package/src/ui/content/Editable.tsx +90 -0
  76. package/src/ui/format/PriceDisplay.tsx +142 -0
  77. package/src/ui/format/useFormatCurrency.ts +92 -0
  78. package/src/ui/headless/agent/useAiAgent.ts +106 -0
  79. package/src/ui/headless/auth/useLoginFlow.ts +72 -0
  80. package/src/ui/headless/auth/useSignupForm.ts +85 -0
  81. package/src/ui/headless/broadcast/useBroadcastWatch.ts +59 -0
  82. package/src/ui/headless/chat/attachmentKind.ts +27 -0
  83. package/src/ui/headless/chat/useChatAttachmentUpload.ts +132 -0
  84. package/src/ui/headless/chat/useChatRoom.ts +223 -0
  85. package/src/ui/headless/chat/useMessageThread.ts +60 -0
  86. package/src/ui/headless/checkout/useCheckout.ts +438 -0
  87. package/src/ui/headless/coaching/useCoachingBooking.ts +223 -0
  88. package/src/ui/headless/community/index.ts +20 -0
  89. package/src/ui/headless/community/lexical.ts +131 -0
  90. package/src/ui/headless/community/replyTree.ts +51 -0
  91. package/src/ui/headless/community/richLexical.ts +314 -0
  92. package/src/ui/headless/community/useCommunityAttachmentUpload.ts +137 -0
  93. package/src/ui/headless/community/useCommunitySpaceFeed.ts +122 -0
  94. package/src/ui/headless/community/useCommunityThread.ts +95 -0
  95. package/src/ui/headless/community/useMentionComposer.ts +105 -0
  96. package/src/ui/headless/community/useRichMentionComposer.ts +193 -0
  97. package/src/ui/headless/consent/useCookieConsent.ts +81 -0
  98. package/src/ui/headless/course/useCourseCheckout.ts +99 -0
  99. package/src/ui/headless/dialog.tsx +14 -0
  100. package/src/ui/headless/document/index.ts +7 -0
  101. package/src/ui/headless/document/useContractSignature.ts +68 -0
  102. package/src/ui/headless/document/useDocumentSigningFlow.ts +92 -0
  103. package/src/ui/headless/document/useOtpCodeInput.ts +86 -0
  104. package/src/ui/headless/document/useSignaturePad.ts +180 -0
  105. package/src/ui/headless/donation/Donation.tsx +115 -0
  106. package/src/ui/headless/donation/DonationContext.tsx +223 -0
  107. package/src/ui/headless/donation/fees.ts +15 -0
  108. package/src/ui/headless/donation/index.ts +10 -0
  109. package/src/ui/headless/engagement/usePostEngagement.ts +119 -0
  110. package/src/ui/headless/event/useEventCheckout.ts +141 -0
  111. package/src/ui/headless/forms/useContactForm.ts +58 -0
  112. package/src/ui/headless/forms/useEmailListForm.ts +75 -0
  113. package/src/ui/headless/forms/useSectionedForm.ts +256 -0
  114. package/src/ui/headless/index.ts +60 -0
  115. package/src/ui/headless/invoice/useInvoicePayment.ts +48 -0
  116. package/src/ui/headless/memberHome/index.ts +7 -0
  117. package/src/ui/headless/memberHome/useMemberHomeNav.ts +82 -0
  118. package/src/ui/headless/membership/MembershipGate.tsx +39 -0
  119. package/src/ui/headless/membership/useMembershipCheckout.ts +121 -0
  120. package/src/ui/headless/offer/Offer.tsx +88 -0
  121. package/src/ui/headless/offer/OfferContext.tsx +162 -0
  122. package/src/ui/headless/offer/index.ts +8 -0
  123. package/src/ui/headless/paymentLink/usePaymentLinkPayment.ts +46 -0
  124. package/src/ui/headless/podcast/usePodcastPlayer.ts +96 -0
  125. package/src/ui/headless/posts/index.ts +13 -0
  126. package/src/ui/headless/posts/upsell.ts +41 -0
  127. package/src/ui/headless/posts/useMemberPostsFeed.ts +110 -0
  128. package/src/ui/headless/posts/usePostCollectionDetail.ts +56 -0
  129. package/src/ui/headless/posts/usePostCollectionsList.ts +90 -0
  130. package/src/ui/headless/posts/usePostItem.ts +81 -0
  131. package/src/ui/headless/reviews/index.ts +6 -0
  132. package/src/ui/headless/reviews/useHelpfulVote.ts +45 -0
  133. package/src/ui/headless/reviews/useReviewForm.ts +108 -0
  134. package/src/ui/headless/reviews/useReviewsList.ts +79 -0
  135. package/src/ui/headless/work/index.ts +25 -0
  136. package/src/ui/headless/work/useWorkPortal.ts +349 -0
  137. package/src/ui/index.ts +144 -0
  138. package/src/ui/payment/ForgePaymentProvider.tsx +45 -0
  139. package/src/ui/payment/ForgeStripePayment.tsx +171 -0
  140. package/src/ui/shell/TribeNestApp.tsx +121 -0
  141. package/src/ui/shell/shellGating.spec.ts +28 -0
  142. package/src/ui/shell/shellGating.ts +11 -0
  143. package/src/ui/styled/AccountDashboard.tsx +575 -0
  144. package/src/ui/styled/AiAgentWidget.tsx +152 -0
  145. package/src/ui/styled/AudioPlayer.tsx +123 -0
  146. package/src/ui/styled/BlogCategory.tsx +79 -0
  147. package/src/ui/styled/BlogComments.tsx +236 -0
  148. package/src/ui/styled/BlogList.tsx +101 -0
  149. package/src/ui/styled/BlogPost.tsx +160 -0
  150. package/src/ui/styled/BlogPostFooter.tsx +33 -0
  151. package/src/ui/styled/BotProtection.tsx +101 -0
  152. package/src/ui/styled/Button.tsx +88 -0
  153. package/src/ui/styled/Cart.tsx +276 -0
  154. package/src/ui/styled/ChatRoom.tsx +252 -0
  155. package/src/ui/styled/Checkout.tsx +988 -0
  156. package/src/ui/styled/CheckoutConfirmation.tsx +452 -0
  157. package/src/ui/styled/CoachingBooking.tsx +605 -0
  158. package/src/ui/styled/CoachingConfirmation.tsx +151 -0
  159. package/src/ui/styled/CoachingDetail.tsx +99 -0
  160. package/src/ui/styled/CohortPage.tsx +82 -0
  161. package/src/ui/styled/ConfirmSubscription.tsx +134 -0
  162. package/src/ui/styled/Confirmation.tsx +171 -0
  163. package/src/ui/styled/ContactForm.tsx +81 -0
  164. package/src/ui/styled/CookieConsent.tsx +239 -0
  165. package/src/ui/styled/CourseAccess.tsx +169 -0
  166. package/src/ui/styled/CourseCheckout.tsx +425 -0
  167. package/src/ui/styled/CourseConfirmation.tsx +191 -0
  168. package/src/ui/styled/CourseDetail.tsx +140 -0
  169. package/src/ui/styled/CoursesGrid.tsx +85 -0
  170. package/src/ui/styled/CurrencySwitcher.tsx +51 -0
  171. package/src/ui/styled/DonationButton.tsx +247 -0
  172. package/src/ui/styled/DonationPage.tsx +29 -0
  173. package/src/ui/styled/EmailListForm.tsx +88 -0
  174. package/src/ui/styled/EventConfirmation.tsx +228 -0
  175. package/src/ui/styled/EventCountdown.tsx +98 -0
  176. package/src/ui/styled/EventDetail.tsx +141 -0
  177. package/src/ui/styled/EventTickets.tsx +548 -0
  178. package/src/ui/styled/EventsList.tsx +83 -0
  179. package/src/ui/styled/ForgotPasswordForm.tsx +105 -0
  180. package/src/ui/styled/FormRenderer.tsx +249 -0
  181. package/src/ui/styled/InstallBanner.tsx +114 -0
  182. package/src/ui/styled/InvoiceConfirmation.tsx +50 -0
  183. package/src/ui/styled/InvoicePayment.tsx +141 -0
  184. package/src/ui/styled/LeadMagnet.tsx +93 -0
  185. package/src/ui/styled/Loading.tsx +56 -0
  186. package/src/ui/styled/LoginForm.tsx +134 -0
  187. package/src/ui/styled/Markdown.tsx +103 -0
  188. package/src/ui/styled/MembershipCheckout.tsx +197 -0
  189. package/src/ui/styled/MembershipTiers.tsx +115 -0
  190. package/src/ui/styled/OfferButton.tsx +181 -0
  191. package/src/ui/styled/PageActions.tsx +124 -0
  192. package/src/ui/styled/PaymentLinkConfirmation.tsx +50 -0
  193. package/src/ui/styled/PaymentLinkPayment.tsx +85 -0
  194. package/src/ui/styled/Paywall.tsx +54 -0
  195. package/src/ui/styled/PlayButton.tsx +48 -0
  196. package/src/ui/styled/PodcastEpisode.tsx +137 -0
  197. package/src/ui/styled/PodcastList.tsx +58 -0
  198. package/src/ui/styled/PodcastShow.tsx +164 -0
  199. package/src/ui/styled/PostsFeed.tsx +63 -0
  200. package/src/ui/styled/ProductDetail.tsx +616 -0
  201. package/src/ui/styled/ProductGrid.tsx +82 -0
  202. package/src/ui/styled/PushOptIn.tsx +65 -0
  203. package/src/ui/styled/PwaRegistration.tsx +58 -0
  204. package/src/ui/styled/ReactionBar.tsx +93 -0
  205. package/src/ui/styled/ReplayList.tsx +134 -0
  206. package/src/ui/styled/ResetPasswordForm.tsx +127 -0
  207. package/src/ui/styled/ReviewForm.tsx +292 -0
  208. package/src/ui/styled/ReviewsSection.tsx +260 -0
  209. package/src/ui/styled/SectionedFormRenderer.tsx +146 -0
  210. package/src/ui/styled/SignupForm.tsx +109 -0
  211. package/src/ui/styled/UserMenu.tsx +166 -0
  212. package/src/ui/styled/chat/ChatAttachments.tsx +172 -0
  213. package/src/ui/styled/chat/ChatComposer.tsx +153 -0
  214. package/src/ui/styled/chat/ChatThreadPanel.tsx +94 -0
  215. package/src/ui/styled/chat/MessageBubble.tsx +66 -0
  216. package/src/ui/styled/chat/NewDmDialog.tsx +78 -0
  217. package/src/ui/styled/community/CommunityComposer.tsx +358 -0
  218. package/src/ui/styled/community/CommunityFeed.tsx +483 -0
  219. package/src/ui/styled/community/CommunityLeaderboard.tsx +109 -0
  220. package/src/ui/styled/community/CommunityMediaGallery.tsx +116 -0
  221. package/src/ui/styled/community/CommunityMemberProfile.tsx +285 -0
  222. package/src/ui/styled/community/CommunityNotifications.tsx +222 -0
  223. package/src/ui/styled/community/CommunityPostCard.tsx +165 -0
  224. package/src/ui/styled/community/CommunityPostDetail.tsx +337 -0
  225. package/src/ui/styled/community/CommunityReactionBar.tsx +107 -0
  226. package/src/ui/styled/community/CommunityRichText.tsx +157 -0
  227. package/src/ui/styled/community/CommunitySpaces.tsx +174 -0
  228. package/src/ui/styled/community/MemberAvatar.tsx +74 -0
  229. package/src/ui/styled/community/index.ts +23 -0
  230. package/src/ui/styled/community/util.ts +15 -0
  231. package/src/ui/styled/members/MemberHomeLayout.tsx +98 -0
  232. package/src/ui/styled/members/MemberPostCard.tsx +392 -0
  233. package/src/ui/styled/members/MemberPostsFeed.tsx +219 -0
  234. package/src/ui/styled/members/PostCollectionDetail.tsx +227 -0
  235. package/src/ui/styled/members/PostCollectionsGrid.tsx +287 -0
  236. package/src/ui/styled/members/index.ts +7 -0
  237. package/src/ui/styled/rich-text.css +150 -0
  238. package/src/ui/styled/work/WorkInviteAccept.tsx +222 -0
  239. package/src/ui/styled/work/WorkPortalProjects.tsx +163 -0
  240. package/src/ui/styled/work/WorkProjectInvoices.tsx +113 -0
  241. package/src/ui/styled/work/WorkProjectReport.tsx +66 -0
  242. package/src/ui/styled/work/WorkReportBody.tsx +327 -0
  243. package/src/ui/styled/work/WorkTaskAttachments.tsx +67 -0
  244. package/src/ui/styled/work/WorkTaskComments.tsx +151 -0
  245. package/src/ui/styled/work/WorkTaskDetail.tsx +92 -0
  246. package/src/ui/styled/work/WorkTokenReport.tsx +49 -0
  247. package/src/ui/styled/work/index.ts +10 -0
  248. package/src/ui/theme/ForgeThemeProvider.tsx +121 -0
  249. package/src/ui/theme/contrast.ts +22 -0
  250. package/src/utils/analyticsBus.ts +31 -0
  251. package/src/utils/attribution.ts +68 -0
  252. package/src/utils/cookieConsent.ts +83 -0
  253. package/src/utils/landing.ts +153 -0
  254. package/src/utils/metaPixel.ts +122 -0
  255. package/src/utils/structuredData.ts +126 -0
@@ -0,0 +1,131 @@
1
+ // Dependency-free Lexical-state helpers. Community post/reply bodies are stored
2
+ // as Lexical JSON on the backend (which flattens them server-side via
3
+ // services/community/lexical.ts). Forge composes states from plain text +
4
+ // mention tokens and renders them without pulling the Lexical runtime in —
5
+ // mirrors the ChatRoom philosophy of zero heavyweight editor deps.
6
+
7
+ export interface LexicalNodeJson {
8
+ type: string;
9
+ version: number;
10
+ [key: string]: unknown;
11
+ children?: LexicalNodeJson[];
12
+ }
13
+
14
+ export interface MentionRef {
15
+ accountId: string;
16
+ /** The display name the token was inserted with (rendered as @name). */
17
+ name: string;
18
+ }
19
+
20
+ const textNode = (text: string): LexicalNodeJson => ({
21
+ type: "text",
22
+ version: 1,
23
+ text,
24
+ detail: 0,
25
+ format: 0,
26
+ mode: "normal",
27
+ style: "",
28
+ });
29
+
30
+ // The backend mention parser (extractMentionAccountIds) accepts the target
31
+ // account under accountId | id | mentionId and reads `mentionName` for the
32
+ // flattened text — we set both accountId and mentionName.
33
+ const mentionNode = (mention: MentionRef): LexicalNodeJson => ({
34
+ type: "mention",
35
+ version: 1,
36
+ accountId: mention.accountId,
37
+ mentionName: mention.name,
38
+ text: `@${mention.name}`,
39
+ detail: 0,
40
+ format: 0,
41
+ mode: "segmented",
42
+ style: "",
43
+ });
44
+
45
+ const paragraphNode = (children: LexicalNodeJson[]): LexicalNodeJson => ({
46
+ type: "paragraph",
47
+ version: 1,
48
+ children,
49
+ direction: null,
50
+ format: "",
51
+ indent: 0,
52
+ });
53
+
54
+ /**
55
+ * Split one line of text into text + mention nodes. A mention only matches on
56
+ * its literal `@Name` token (the composer inserts them verbatim).
57
+ */
58
+ function lineToChildren(line: string, mentions: MentionRef[]): LexicalNodeJson[] {
59
+ if (!mentions.length) return line ? [textNode(line)] : [];
60
+
61
+ // Longest names first so "@Ann Lee" wins over "@Ann".
62
+ const sorted = [...mentions].sort((a, b) => b.name.length - a.name.length);
63
+ const children: LexicalNodeJson[] = [];
64
+ let rest = line;
65
+
66
+ while (rest.length) {
67
+ let earliest: { index: number; mention: MentionRef } | null = null;
68
+ for (const mention of sorted) {
69
+ const idx = rest.indexOf(`@${mention.name}`);
70
+ if (idx !== -1 && (earliest === null || idx < earliest.index)) {
71
+ earliest = { index: idx, mention };
72
+ }
73
+ }
74
+ if (!earliest) {
75
+ children.push(textNode(rest));
76
+ break;
77
+ }
78
+ if (earliest.index > 0) children.push(textNode(rest.slice(0, earliest.index)));
79
+ children.push(mentionNode(earliest.mention));
80
+ rest = rest.slice(earliest.index + earliest.mention.name.length + 1);
81
+ }
82
+
83
+ return children;
84
+ }
85
+
86
+ /**
87
+ * Build a Lexical editor state (JSON) from plain text + mention refs. Each
88
+ * newline becomes a paragraph; `@Name` occurrences of tracked mentions become
89
+ * mention nodes the backend turns into notifications.
90
+ */
91
+ export function buildLexicalState(text: string, mentions: MentionRef[] = []): { root: LexicalNodeJson } {
92
+ const lines = text.replace(/\r\n/g, "\n").split("\n");
93
+ return {
94
+ root: {
95
+ type: "root",
96
+ version: 1,
97
+ direction: null,
98
+ format: "",
99
+ indent: 0,
100
+ children: lines.map((line) => paragraphNode(lineToChildren(line, mentions))),
101
+ },
102
+ };
103
+ }
104
+
105
+ /** Which tracked mentions still literally appear in the text (post-edit). */
106
+ export function mentionsInText(text: string, mentions: MentionRef[]): MentionRef[] {
107
+ return mentions.filter((m) => text.includes(`@${m.name}`));
108
+ }
109
+
110
+ const BLOCK_TYPES = new Set(["paragraph", "heading", "quote", "listitem", "code"]);
111
+
112
+ /** Flatten a Lexical state (or any unknown JSON) to plain text — preview/fallback. */
113
+ export function lexicalToPlainText(body: unknown): string {
114
+ if (!body || typeof body !== "object") return typeof body === "string" ? body : "";
115
+ const root = (body as { root?: LexicalNodeJson }).root ?? (body as LexicalNodeJson);
116
+ const parts: string[] = [];
117
+
118
+ const walk = (node: LexicalNodeJson) => {
119
+ if (typeof node.text === "string") parts.push(node.text as string);
120
+ else if (node.type === "mention" && typeof node.mentionName === "string") parts.push(`@${node.mentionName}`);
121
+ if (Array.isArray(node.children)) for (const child of node.children) walk(child);
122
+ if (node.type && BLOCK_TYPES.has(node.type)) parts.push("\n");
123
+ };
124
+
125
+ walk(root);
126
+ return parts
127
+ .join(" ")
128
+ .replace(/[ \t]+/g, " ")
129
+ .replace(/\s*\n\s*/g, "\n")
130
+ .trim();
131
+ }
@@ -0,0 +1,51 @@
1
+ import type { CommunityReply } from "../../../data/queries/useCommunity";
2
+
3
+ export interface ReplyNode {
4
+ reply: CommunityReply;
5
+ children: ReplyNode[];
6
+ /** Depth in the FULL tree (0 = direct reply to the post). */
7
+ depth: number;
8
+ }
9
+
10
+ /**
11
+ * Assemble the nested reply tree from the flat, chronologically-ordered pages
12
+ * the API returns. Nesting is UNLIMITED in the data model; capping what is
13
+ * rendered (display cap 3 levels + "continue thread") is the component's job.
14
+ *
15
+ * Replies whose parent isn't in the loaded set (archived, or on an unloaded
16
+ * page) surface at the top level rather than disappearing.
17
+ */
18
+ export function buildReplyTree(replies: CommunityReply[]): ReplyNode[] {
19
+ const nodes = new Map<string, ReplyNode>();
20
+ const roots: ReplyNode[] = [];
21
+
22
+ for (const reply of replies) {
23
+ nodes.set(reply.id, { reply, children: [], depth: 0 });
24
+ }
25
+ for (const reply of replies) {
26
+ const node = nodes.get(reply.id)!;
27
+ const parent = reply.parentReplyId ? nodes.get(reply.parentReplyId) : undefined;
28
+ if (parent) parent.children.push(node);
29
+ else roots.push(node);
30
+ }
31
+
32
+ const stamp = (list: ReplyNode[], depth: number) => {
33
+ for (const node of list) {
34
+ node.depth = depth;
35
+ stamp(node.children, depth + 1);
36
+ }
37
+ };
38
+ stamp(roots, 0);
39
+
40
+ return roots;
41
+ }
42
+
43
+ /** Find one node in a tree by reply id (used to re-root a "continue thread" view). */
44
+ export function findReplyNode(tree: ReplyNode[], replyId: string): ReplyNode | null {
45
+ for (const node of tree) {
46
+ if (node.reply.id === replyId) return node;
47
+ const found = findReplyNode(node.children, replyId);
48
+ if (found) return found;
49
+ }
50
+ return null;
51
+ }
@@ -0,0 +1,314 @@
1
+ // DOM → Lexical-state serializer for the rich community composer. The
2
+ // contentEditable div is the source of truth; here we walk its DOM and emit the
3
+ // exact Lexical JSON shapes CommunityRichText renders and the create-post API
4
+ // stores — WITHOUT pulling the Lexical runtime into forge. Mirrors the node
5
+ // shapes in ./lexical.ts (buildLexicalState) so plain-text and rich content
6
+ // round-trip through the same renderer.
7
+
8
+ import type { LexicalNodeJson, MentionRef } from "./lexical";
9
+
10
+ // Lexical text-format bitmask (matches CommunityRichText):
11
+ const FORMAT_BOLD = 1;
12
+ const FORMAT_ITALIC = 2;
13
+ const FORMAT_STRIKETHROUGH = 4;
14
+ const FORMAT_UNDERLINE = 8;
15
+
16
+ interface InlineFormat {
17
+ format: number;
18
+ /** When inside an <a>, the nearest ancestor link URL. */
19
+ linkUrl: string | null;
20
+ }
21
+
22
+ const textNode = (text: string, format: number): LexicalNodeJson => ({
23
+ type: "text",
24
+ version: 1,
25
+ text,
26
+ detail: 0,
27
+ format,
28
+ mode: "normal",
29
+ style: "",
30
+ });
31
+
32
+ const mentionNode = (accountId: string, name: string): LexicalNodeJson => ({
33
+ type: "mention",
34
+ version: 1,
35
+ accountId,
36
+ mentionName: name,
37
+ text: `@${name}`,
38
+ detail: 0,
39
+ format: 0,
40
+ mode: "segmented",
41
+ style: "",
42
+ });
43
+
44
+ const linebreakNode = (): LexicalNodeJson => ({ type: "linebreak", version: 1 });
45
+
46
+ const linkNode = (url: string, children: LexicalNodeJson[]): LexicalNodeJson => ({
47
+ type: "link",
48
+ version: 1,
49
+ url,
50
+ rel: "noopener noreferrer",
51
+ target: "_blank",
52
+ title: null,
53
+ children,
54
+ direction: null,
55
+ format: "",
56
+ indent: 0,
57
+ });
58
+
59
+ const paragraphNode = (children: LexicalNodeJson[]): LexicalNodeJson => ({
60
+ type: "paragraph",
61
+ version: 1,
62
+ children,
63
+ direction: null,
64
+ format: "",
65
+ indent: 0,
66
+ });
67
+
68
+ const headingNode = (tag: string, children: LexicalNodeJson[]): LexicalNodeJson => ({
69
+ type: "heading",
70
+ version: 1,
71
+ tag,
72
+ children,
73
+ direction: null,
74
+ format: "",
75
+ indent: 0,
76
+ });
77
+
78
+ const quoteNode = (children: LexicalNodeJson[]): LexicalNodeJson => ({
79
+ type: "quote",
80
+ version: 1,
81
+ children,
82
+ direction: null,
83
+ format: "",
84
+ indent: 0,
85
+ });
86
+
87
+ const listNode = (listType: "bullet" | "number", children: LexicalNodeJson[]): LexicalNodeJson => ({
88
+ type: "list",
89
+ version: 1,
90
+ listType,
91
+ tag: listType === "number" ? "ol" : "ul",
92
+ start: 1,
93
+ children,
94
+ direction: null,
95
+ format: "",
96
+ indent: 0,
97
+ });
98
+
99
+ const listItemNode = (children: LexicalNodeJson[], value: number): LexicalNodeJson => ({
100
+ type: "listitem",
101
+ version: 1,
102
+ value,
103
+ children,
104
+ direction: null,
105
+ format: "",
106
+ indent: 0,
107
+ });
108
+
109
+ const isElement = (n: Node): n is HTMLElement => n.nodeType === 1;
110
+ const isText = (n: Node): n is Text => n.nodeType === 3;
111
+
112
+ /** Tag-driven format bits contributed by an inline wrapper element. */
113
+ function tagFormatBit(tag: string): number {
114
+ switch (tag) {
115
+ case "b":
116
+ case "strong":
117
+ return FORMAT_BOLD;
118
+ case "i":
119
+ case "em":
120
+ return FORMAT_ITALIC;
121
+ case "u":
122
+ return FORMAT_UNDERLINE;
123
+ case "s":
124
+ case "strike":
125
+ case "del":
126
+ return FORMAT_STRIKETHROUGH;
127
+ default:
128
+ return 0;
129
+ }
130
+ }
131
+
132
+ /** Inline-format bits contributed by an element's own style attribute. */
133
+ function styleFormatBits(el: HTMLElement): number {
134
+ let bits = 0;
135
+ const style = el.style;
136
+ const fw = style.fontWeight;
137
+ if (fw === "bold" || fw === "bolder" || (fw !== "" && Number(fw) >= 600)) bits |= FORMAT_BOLD;
138
+ if (style.fontStyle === "italic") bits |= FORMAT_ITALIC;
139
+ const deco = `${style.textDecoration} ${style.textDecorationLine}`;
140
+ if (deco.includes("underline")) bits |= FORMAT_UNDERLINE;
141
+ if (deco.includes("line-through")) bits |= FORMAT_STRIKETHROUGH;
142
+ return bits;
143
+ }
144
+
145
+ const BLOCK_TAGS = new Set(["p", "div", "ul", "ol", "li", "blockquote", "h1", "h2", "h3", "h4", "h5", "h6"]);
146
+
147
+ /** Serialize inline content (text runs, mentions, links, <br>) under a block. */
148
+ function serializeInline(nodes: NodeListOf<ChildNode> | ChildNode[], inherited: InlineFormat): LexicalNodeJson[] {
149
+ const out: LexicalNodeJson[] = [];
150
+
151
+ const push = (child: LexicalNodeJson) => {
152
+ // Wrap text/mention runs in a link node if we're inside an <a>.
153
+ if (inherited.linkUrl) {
154
+ const last = out[out.length - 1];
155
+ if (last && last.type === "link" && last.url === inherited.linkUrl && Array.isArray(last.children)) {
156
+ last.children.push(child);
157
+ return;
158
+ }
159
+ out.push(linkNode(inherited.linkUrl, [child]));
160
+ return;
161
+ }
162
+ out.push(child);
163
+ };
164
+
165
+ nodes.forEach((node) => {
166
+ if (isText(node)) {
167
+ const text = node.textContent ?? "";
168
+ if (text.length) push(textNode(text, inherited.format));
169
+ return;
170
+ }
171
+ if (!isElement(node)) return;
172
+
173
+ const tag = node.tagName.toLowerCase();
174
+
175
+ if (tag === "br") {
176
+ push(linebreakNode());
177
+ return;
178
+ }
179
+
180
+ // Mention chip: a non-editable span carrying the account id.
181
+ const accountId = node.getAttribute("data-account-id");
182
+ if (accountId) {
183
+ const name = (node.getAttribute("data-mention-name") ?? node.textContent ?? "").replace(/^@/, "");
184
+ push(mentionNode(accountId, name));
185
+ return;
186
+ }
187
+
188
+ if (tag === "a") {
189
+ const url = node.getAttribute("href") || "#";
190
+ const nested = serializeInline(node.childNodes, { format: inherited.format, linkUrl: url });
191
+ // serializeInline already wraps in link nodes when linkUrl is set.
192
+ for (const child of nested) push(child);
193
+ return;
194
+ }
195
+
196
+ // Inline formatting wrapper (b/strong/i/em/u/s) or a styled span.
197
+ const nextFormat = inherited.format | tagFormatBit(tag) | styleFormatBits(node);
198
+ const nested = serializeInline(node.childNodes, { format: nextFormat, linkUrl: inherited.linkUrl });
199
+ for (const child of nested) push(child);
200
+ });
201
+
202
+ return out;
203
+ }
204
+
205
+ /**
206
+ * Walk block-level children of the contentEditable root, emitting paragraph /
207
+ * heading / quote / list block nodes. Loose inline nodes at the root (text the
208
+ * browser left un-wrapped) collect into an implicit paragraph.
209
+ */
210
+ function serializeBlocks(root: HTMLElement): LexicalNodeJson[] {
211
+ const blocks: LexicalNodeJson[] = [];
212
+ let looseInline: LexicalNodeJson[] = [];
213
+
214
+ const flushLoose = () => {
215
+ if (looseInline.length) {
216
+ blocks.push(paragraphNode(looseInline));
217
+ looseInline = [];
218
+ }
219
+ };
220
+
221
+ const emitList = (el: HTMLElement) => {
222
+ const listType: "bullet" | "number" = el.tagName.toLowerCase() === "ol" ? "number" : "bullet";
223
+ const items: LexicalNodeJson[] = [];
224
+ let value = 1;
225
+ el.childNodes.forEach((li) => {
226
+ if (isElement(li) && li.tagName.toLowerCase() === "li") {
227
+ // Nested lists inside an <li> render as their own list block below the
228
+ // item content; keep it lean by flattening inline content only.
229
+ const inline = serializeInline(li.childNodes, { format: 0, linkUrl: null });
230
+ items.push(listItemNode(inline, value));
231
+ value += 1;
232
+ }
233
+ });
234
+ if (items.length) blocks.push(listNode(listType, items));
235
+ };
236
+
237
+ const emitBlockElement = (el: HTMLElement) => {
238
+ const tag = el.tagName.toLowerCase();
239
+ if (tag === "ul" || tag === "ol") {
240
+ flushLoose();
241
+ emitList(el);
242
+ return;
243
+ }
244
+ if (tag === "blockquote") {
245
+ flushLoose();
246
+ blocks.push(quoteNode(serializeInline(el.childNodes, { format: 0, linkUrl: null })));
247
+ return;
248
+ }
249
+ if (tag === "h1" || tag === "h2" || tag === "h3" || tag === "h4" || tag === "h5" || tag === "h6") {
250
+ flushLoose();
251
+ blocks.push(headingNode(tag, serializeInline(el.childNodes, { format: 0, linkUrl: null })));
252
+ return;
253
+ }
254
+ // p / div → paragraph.
255
+ flushLoose();
256
+ blocks.push(paragraphNode(serializeInline(el.childNodes, { format: 0, linkUrl: null })));
257
+ };
258
+
259
+ root.childNodes.forEach((node) => {
260
+ if (isText(node)) {
261
+ const text = node.textContent ?? "";
262
+ if (text.length) looseInline.push(textNode(text, 0));
263
+ return;
264
+ }
265
+ if (!isElement(node)) return;
266
+ const tag = node.tagName.toLowerCase();
267
+ if (tag === "br") {
268
+ looseInline.push(linebreakNode());
269
+ return;
270
+ }
271
+ if (BLOCK_TAGS.has(tag)) {
272
+ emitBlockElement(node);
273
+ return;
274
+ }
275
+ // Inline element sitting directly at root → gather into a paragraph.
276
+ for (const child of serializeInline([node], { format: 0, linkUrl: null })) looseInline.push(child);
277
+ });
278
+
279
+ flushLoose();
280
+ if (!blocks.length) blocks.push(paragraphNode([]));
281
+ return blocks;
282
+ }
283
+
284
+ /** Serialize a contentEditable root element to a Lexical editor state. */
285
+ export function serializeRichToLexical(root: HTMLElement): { root: LexicalNodeJson } {
286
+ return {
287
+ root: {
288
+ type: "root",
289
+ version: 1,
290
+ direction: null,
291
+ format: "",
292
+ indent: 0,
293
+ children: serializeBlocks(root),
294
+ },
295
+ };
296
+ }
297
+
298
+ /** Collect unique mention refs (chips) present in a contentEditable root. */
299
+ export function collectMentions(root: HTMLElement): MentionRef[] {
300
+ const seen = new Map<string, MentionRef>();
301
+ root.querySelectorAll<HTMLElement>("[data-account-id]").forEach((el) => {
302
+ const accountId = el.getAttribute("data-account-id");
303
+ if (!accountId || seen.has(accountId)) return;
304
+ const name = (el.getAttribute("data-mention-name") ?? el.textContent ?? "").replace(/^@/, "");
305
+ seen.set(accountId, { accountId, name });
306
+ });
307
+ return [...seen.values()];
308
+ }
309
+
310
+ /** Whether the editor holds any real content (text or a mention chip). */
311
+ export function isRichEmpty(root: HTMLElement): boolean {
312
+ if (root.querySelector("[data-account-id]")) return false;
313
+ return (root.textContent ?? "").trim().length === 0;
314
+ }
@@ -0,0 +1,137 @@
1
+ import axios from "axios";
2
+ import { useCallback, useRef, useState } from "react";
3
+ import { useForge } from "../../../provider/ForgeProvider";
4
+ import { attachmentKind, type AttachmentKind } from "../chat/attachmentKind";
5
+ import type { CommunityAttachmentInput } from "../../../data/queries/useCommunity";
6
+
7
+ export interface PendingCommunityAttachment {
8
+ id: string;
9
+ file: File;
10
+ kind: AttachmentKind;
11
+ /** objectURL for image/video/audio local preview, revoked on clear/remove. */
12
+ previewUrl?: string;
13
+ progress: number;
14
+ status: "pending" | "uploading" | "done" | "error";
15
+ uploaded?: CommunityAttachmentInput;
16
+ }
17
+
18
+ const MAX_ATTACHMENTS = 10;
19
+ /** Max size for a single community attachment (100MB). Mirrors the backend cap. */
20
+ export const MAX_COMMUNITY_ATTACHMENT_BYTES = 100 * 1024 * 1024;
21
+ let counter = 0;
22
+ const nextId = () => `catt-${Date.now()}-${counter++}`;
23
+
24
+ /** Map the shared preview kind onto the community attachment enum the API accepts. */
25
+ const toApiType = (kind: AttachmentKind): CommunityAttachmentInput["type"] => {
26
+ if (kind === "image" || kind === "video" || kind === "audio") return kind;
27
+ if (kind === "pdf") return "document";
28
+ return "other";
29
+ };
30
+
31
+ /**
32
+ * Headless attachment composer for community posts/replies: stage local files
33
+ * (with previews), upload to R2 via the member-scoped presigned endpoint
34
+ * (POST /public/community/attachments/presigned-url — same flow as chat's
35
+ * useChatAttachmentUpload), and hand back `CommunityAttachmentInput[]`.
36
+ */
37
+ export function useCommunityAttachmentUpload() {
38
+ const { client, profileId } = useForge();
39
+ const [pending, setPending] = useState<PendingCommunityAttachment[]>([]);
40
+ const [error, setError] = useState<string | null>(null);
41
+ const pendingRef = useRef<PendingCommunityAttachment[]>([]);
42
+ pendingRef.current = pending;
43
+
44
+ const patch = useCallback((id: string, updates: Partial<PendingCommunityAttachment>) => {
45
+ setPending((prev) => prev.map((p) => (p.id === id ? { ...p, ...updates } : p)));
46
+ }, []);
47
+
48
+ const addFiles = useCallback((files: FileList | File[]) => {
49
+ const incoming = Array.from(files);
50
+ const oversized = incoming.filter((f) => f.size > MAX_COMMUNITY_ATTACHMENT_BYTES);
51
+ const allowed = incoming.filter((f) => f.size <= MAX_COMMUNITY_ATTACHMENT_BYTES);
52
+ setError(
53
+ oversized.length
54
+ ? `${oversized.length === 1 ? `"${oversized[0].name}" exceeds` : `${oversized.length} files exceed`} the 100MB limit and ${oversized.length === 1 ? "was" : "were"} skipped.`
55
+ : null,
56
+ );
57
+ setPending((prev) => {
58
+ const room = MAX_ATTACHMENTS - prev.length;
59
+ const next = allowed.slice(0, Math.max(0, room)).map((file) => {
60
+ const kind = attachmentKind({ fileType: file.type, fileName: file.name });
61
+ const previewUrl =
62
+ typeof URL !== "undefined" && (kind === "image" || kind === "video" || kind === "audio")
63
+ ? URL.createObjectURL(file)
64
+ : undefined;
65
+ return { id: nextId(), file, kind, previewUrl, progress: 0, status: "pending" as const };
66
+ });
67
+ return [...prev, ...next];
68
+ });
69
+ }, []);
70
+
71
+ const removeFile = useCallback((id: string) => {
72
+ setPending((prev) => {
73
+ const target = prev.find((p) => p.id === id);
74
+ if (target?.previewUrl) URL.revokeObjectURL(target.previewUrl);
75
+ return prev.filter((p) => p.id !== id);
76
+ });
77
+ }, []);
78
+
79
+ const clear = useCallback(() => {
80
+ pendingRef.current.forEach((p) => p.previewUrl && URL.revokeObjectURL(p.previewUrl));
81
+ setPending([]);
82
+ setError(null);
83
+ }, []);
84
+
85
+ /** Upload every staged file and return the send-ready attachment inputs. */
86
+ const uploadAll = useCallback(async (): Promise<CommunityAttachmentInput[]> => {
87
+ const items = pendingRef.current;
88
+ const uploaded: CommunityAttachmentInput[] = [];
89
+
90
+ for (const item of items) {
91
+ if (item.status === "done" && item.uploaded) {
92
+ uploaded.push(item.uploaded);
93
+ continue;
94
+ }
95
+ patch(item.id, { status: "uploading", progress: 0 });
96
+ try {
97
+ const { data } = await client.post<{ presignedUrl: string; remoteUrl: string }>(
98
+ "/public/community/attachments/presigned-url",
99
+ { fileName: item.file.name, profileId },
100
+ );
101
+ await axios.put(data.presignedUrl, item.file, {
102
+ headers: { "Content-Type": item.file.type || "application/octet-stream" },
103
+ onUploadProgress: (e) => {
104
+ const total = e.total ?? item.file.size;
105
+ if (total) patch(item.id, { progress: Math.round((e.loaded / total) * 100) });
106
+ },
107
+ });
108
+ const attachment: CommunityAttachmentInput = {
109
+ url: data.remoteUrl,
110
+ type: toApiType(item.kind),
111
+ size: item.file.size,
112
+ name: item.file.name,
113
+ };
114
+ patch(item.id, { status: "done", progress: 100, uploaded: attachment });
115
+ uploaded.push(attachment);
116
+ } catch (err) {
117
+ patch(item.id, { status: "error" });
118
+ setError(`Couldn't upload "${item.file.name}". Please try again.`);
119
+ throw err;
120
+ }
121
+ }
122
+
123
+ return uploaded;
124
+ }, [client, profileId, patch]);
125
+
126
+ return {
127
+ pending,
128
+ error,
129
+ addFiles,
130
+ removeFile,
131
+ uploadAll,
132
+ clear,
133
+ isUploading: pending.some((p) => p.status === "uploading"),
134
+ hasPending: pending.length > 0,
135
+ canAddMore: pending.length < MAX_ATTACHMENTS,
136
+ };
137
+ }