@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.
- package/package.json +5 -2
- package/src/contexts/CartContext.tsx +34 -1
- package/src/contexts/PublicAuthContext.tsx +15 -3
- package/src/data/queries/_tests/metaSignals.spec.tsx +130 -0
- package/src/data/queries/useAccount.ts +33 -2
- package/src/data/queries/useAnalytics.ts +6 -6
- package/src/data/queries/useCallChat.ts +2 -2
- package/src/data/queries/useChat.ts +10 -1
- package/src/data/queries/useCheckouts.ts +2 -1
- package/src/data/queries/useCoachingAvailability.ts +10 -1
- package/src/data/queries/useCommunities.ts +2 -0
- package/src/data/queries/useCommunity.ts +362 -18
- package/src/data/queries/useCommunityAgent.ts +247 -0
- package/src/data/queries/useCommunityCalendar.ts +150 -0
- package/src/data/queries/useCommunityClassroom.ts +75 -0
- package/src/data/queries/useCommunityMembers.ts +108 -0
- package/src/data/queries/useCommunityModeration.ts +327 -0
- package/src/data/queries/useCommunityWiki.ts +137 -0
- package/src/data/queries/useCourses.ts +2 -0
- package/src/data/queries/useEmailSignIn.ts +79 -0
- package/src/data/queries/useEvents.ts +6 -1
- package/src/data/queries/useForms.ts +5 -1
- package/src/data/queries/useLeadMagnet.ts +6 -2
- package/src/data/queries/useMyBookings.ts +24 -1
- package/src/data/queries/useMyTickets.ts +24 -1
- package/src/data/queries/useNotifications.ts +6 -2
- package/src/data/queries/useOrders.ts +2 -1
- package/src/data/queries/usePWA.ts +111 -35
- package/src/data/queries/useSiteApp.ts +130 -0
- package/src/data/queries/useSubscriptions.ts +6 -1
- package/src/i18n/de.json +746 -1
- package/src/i18n/en.json +746 -1
- package/src/index.ts +20 -0
- package/src/runtime/pages.spec.ts +5 -0
- package/src/runtime/pages.ts +32 -6
- package/src/seo/_tests/seo.spec.ts +326 -0
- package/src/seo/applySeoToHead.ts +98 -0
- package/src/seo/index.ts +9 -0
- package/src/seo/resolve.ts +211 -0
- package/src/seo/seoHead.ts +61 -0
- package/src/seo/siteSeoHead.ts +97 -0
- package/src/seo/types.ts +99 -0
- package/src/server/_tests/siteBootstrap.spec.ts +65 -0
- package/src/server/index.ts +33 -4
- package/src/types/models.ts +11 -0
- package/src/ui/analytics/ForgeAnalytics.tsx +134 -34
- package/src/ui/analytics/PageMetaPixel.tsx +23 -8
- package/src/ui/analytics/_tests/ForgeAnalytics.meta.spec.tsx +129 -0
- package/src/ui/analytics/_tests/ForgeAnalytics.webVitals.spec.tsx +129 -0
- package/src/ui/analytics/_tests/metaCommerceEvents.spec.tsx +159 -0
- package/src/ui/analytics/useMetaEvents.ts +78 -0
- package/src/ui/headless/_tests/communityAgent.spec.tsx +519 -0
- package/src/ui/headless/_tests/communityCalendar.spec.tsx +195 -0
- package/src/ui/headless/_tests/communityClassroom.spec.tsx +120 -0
- package/src/ui/headless/_tests/communityHarness.tsx +10 -3
- package/src/ui/headless/_tests/communityMembers.spec.tsx +316 -0
- package/src/ui/headless/_tests/communityPoll.spec.tsx +389 -0
- package/src/ui/headless/_tests/memberHomeBar.spec.ts +84 -0
- package/src/ui/headless/_tests/siteAppBar.spec.ts +83 -0
- package/src/ui/headless/_tests/siteAppHarness.ts +19 -0
- package/src/ui/headless/_tests/siteAppHome.spec.ts +226 -0
- package/src/ui/headless/_tests/siteAppPrompts.spec.ts +185 -0
- package/src/ui/headless/_tests/useCommunityJoin.spec.tsx +228 -14
- package/src/ui/headless/_tests/useCommunityMembership.spec.tsx +29 -1
- package/src/ui/headless/_tests/useCommunityModeration.spec.tsx +420 -0
- package/src/ui/headless/_tests/useCommunityReads.spec.tsx +11 -2
- package/src/ui/headless/_tests/useCommunityWiki.spec.tsx +224 -0
- package/src/ui/headless/_tests/useSiteApp.spec.tsx +52 -0
- package/src/ui/headless/_tests/wikiMediaImage.spec.tsx +132 -0
- package/src/ui/headless/auth/useEmailCodeSignIn.ts +186 -0
- package/src/ui/headless/auth/useSignupForm.ts +20 -1
- package/src/ui/headless/chat/useChatAttachmentUpload.ts +9 -6
- package/src/ui/headless/chat/useChatRoom.ts +35 -1
- package/src/ui/headless/communities/index.ts +41 -0
- package/src/ui/headless/communities/useCommunityCalendar.ts +156 -0
- package/src/ui/headless/communities/useCommunityClassroom.ts +84 -0
- package/src/ui/headless/communities/useCommunityHome.ts +3 -0
- package/src/ui/headless/communities/useCommunityJoin.ts +73 -23
- package/src/ui/headless/communities/useCommunityMembership.ts +48 -2
- package/src/ui/headless/communities/useCommunityWiki.ts +266 -0
- package/src/ui/headless/community/communityPoll.ts +297 -0
- package/src/ui/headless/community/countries.ts +123 -0
- package/src/ui/headless/community/index.ts +81 -1
- package/src/ui/headless/community/linkPreview.ts +71 -0
- package/src/ui/headless/community/richLexical.ts +27 -2
- package/src/ui/headless/community/useCommunityAgent.ts +285 -0
- package/src/ui/headless/community/useCommunityMembers.ts +175 -0
- package/src/ui/headless/community/useCommunityModeration.ts +300 -0
- package/src/ui/headless/community/useCommunitySpaceFeed.ts +22 -4
- package/src/ui/headless/community/useCommunityThread.ts +16 -3
- package/src/ui/headless/community/useMentionComposer.ts +17 -9
- package/src/ui/headless/community/useRichMentionComposer.ts +21 -5
- package/src/ui/headless/downloads/index.ts +8 -0
- package/src/ui/headless/downloads/useDownloadPage.ts +134 -0
- package/src/ui/headless/index.ts +13 -0
- package/src/ui/headless/memberHome/index.ts +10 -0
- package/src/ui/headless/memberHome/memberHomeBar.ts +91 -0
- package/src/ui/headless/memberHome/useMemberHomeNav.ts +56 -18
- package/src/ui/headless/portal/index.ts +57 -0
- package/src/ui/headless/portal/usePortal.ts +274 -0
- package/src/ui/headless/portal/usePortalFiles.ts +68 -0
- package/src/ui/headless/portal/usePortalMessages.ts +189 -0
- package/src/ui/headless/portal/usePortalPreview.ts +142 -0
- package/src/ui/headless/siteApp/appBar.ts +102 -0
- package/src/ui/headless/siteApp/appHome.ts +344 -0
- package/src/ui/headless/siteApp/appPrompts.ts +229 -0
- package/src/ui/headless/siteApp/index.ts +58 -0
- package/src/ui/headless/siteApp/useAppHome.ts +210 -0
- package/src/ui/headless/siteApp/useAppInstallRequest.ts +16 -0
- package/src/ui/headless/work/index.ts +2 -0
- package/src/ui/headless/work/useWorkPortal.ts +37 -26
- package/src/ui/index.ts +11 -0
- package/src/ui/styled/AccountDashboard.tsx +712 -352
- package/src/ui/styled/AiAgentWidget.tsx +13 -1
- package/src/ui/styled/ChatRoom.tsx +126 -16
- package/src/ui/styled/Checkout.tsx +8 -0
- package/src/ui/styled/CheckoutConfirmation.tsx +8 -0
- package/src/ui/styled/CoachingConfirmation.tsx +8 -0
- package/src/ui/styled/CoachingDetail.tsx +2 -0
- package/src/ui/styled/CourseConfirmation.tsx +5 -0
- package/src/ui/styled/CourseDetail.tsx +2 -0
- package/src/ui/styled/DownloadPage.tsx +263 -0
- package/src/ui/styled/EventConfirmation.tsx +10 -0
- package/src/ui/styled/EventDetail.tsx +10 -0
- package/src/ui/styled/FilmLibrary.tsx +22 -9
- package/src/ui/styled/MusicLinkPage.tsx +5 -5
- package/src/ui/styled/ProductDetail.tsx +12 -0
- package/src/ui/styled/PwaRegistration.tsx +5 -1
- package/src/ui/styled/SignupForm.tsx +18 -3
- package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +5 -5
- package/src/ui/styled/_tests/AccountNotifications.spec.tsx +135 -0
- package/src/ui/styled/_tests/DownloadPage.spec.tsx +68 -0
- package/src/ui/styled/app/AppBar.tsx +298 -0
- package/src/ui/styled/app/AppHome.tsx +1139 -0
- package/src/ui/styled/app/AppHomeMark.tsx +66 -0
- package/src/ui/styled/app/AppPrompts.tsx +464 -0
- package/src/ui/styled/app/_tests/AppBar.spec.tsx +96 -0
- package/src/ui/styled/app/_tests/AppHome.spec.tsx +209 -0
- package/src/ui/styled/app/_tests/AppPrompts.spec.tsx +232 -0
- package/src/ui/styled/app/index.ts +7 -0
- package/src/ui/styled/chat/ChatThreadPanel.tsx +29 -4
- package/src/ui/styled/chat/MessageBubble.tsx +43 -6
- package/src/ui/styled/communities/CommunityAbout.tsx +6 -1
- package/src/ui/styled/communities/CommunityCalendar.tsx +484 -0
- package/src/ui/styled/communities/CommunityClassroom.tsx +302 -0
- package/src/ui/styled/communities/CommunityHome.tsx +134 -11
- package/src/ui/styled/communities/CommunityJoinSheet.tsx +119 -19
- package/src/ui/styled/communities/CommunityMembershipMenu.tsx +168 -52
- package/src/ui/styled/communities/CommunityPublicWiki.tsx +75 -0
- package/src/ui/styled/communities/CommunityWiki.tsx +289 -0
- package/src/ui/styled/communities/MyMembershipsList.tsx +31 -5
- package/src/ui/styled/communities/_tests/CommunityAppHome.spec.tsx +92 -0
- package/src/ui/styled/communities/_tests/CommunityClassroomCalendar.spec.tsx +453 -0
- package/src/ui/styled/communities/_tests/CommunityMobile.spec.tsx +399 -0
- package/src/ui/styled/communities/_tests/CommunityWiki.spec.tsx +223 -0
- package/src/ui/styled/communities/index.ts +9 -0
- package/src/ui/styled/communities/shared.tsx +39 -11
- package/src/ui/styled/community/CommunityAgent.tsx +286 -0
- package/src/ui/styled/community/CommunityComposer.tsx +266 -15
- package/src/ui/styled/community/CommunityFeed.tsx +50 -1
- package/src/ui/styled/community/CommunityLinkPreview.tsx +159 -0
- package/src/ui/styled/community/CommunityMemberGlobe.tsx +171 -0
- package/src/ui/styled/community/CommunityMemberProfile.tsx +137 -4
- package/src/ui/styled/community/CommunityMembers.tsx +387 -0
- package/src/ui/styled/community/CommunityModeration.tsx +587 -0
- package/src/ui/styled/community/CommunityModerationState.tsx +127 -0
- package/src/ui/styled/community/CommunityModeratorMenu.tsx +317 -0
- package/src/ui/styled/community/CommunityNotifications.tsx +52 -6
- package/src/ui/styled/community/CommunityPollCard.tsx +250 -0
- package/src/ui/styled/community/CommunityPostCard.tsx +110 -34
- package/src/ui/styled/community/CommunityPostDetail.tsx +157 -71
- package/src/ui/styled/community/CommunityRichText.tsx +177 -32
- package/src/ui/styled/community/CommunitySpaces.tsx +49 -14
- package/src/ui/styled/community/_tests/CommunityAgent.spec.tsx +571 -0
- package/src/ui/styled/community/_tests/CommunityLinkPreview.spec.tsx +167 -0
- package/src/ui/styled/community/_tests/CommunityMembers.spec.tsx +137 -0
- package/src/ui/styled/community/_tests/CommunityModeration.spec.tsx +291 -0
- package/src/ui/styled/community/_tests/CommunityPoll.spec.tsx +386 -0
- package/src/ui/styled/community/index.ts +37 -2
- package/src/ui/styled/forge-utilities.css +234 -0
- package/src/ui/styled/members/CallRecordingActions.tsx +182 -0
- package/src/ui/styled/members/MemberCallsCalendar.tsx +13 -158
- package/src/ui/styled/members/MemberHomeLayout.tsx +295 -15
- package/src/ui/styled/members/MemberPostsFeed.tsx +3 -2
- package/src/ui/styled/members/index.ts +8 -0
- package/src/ui/styled/portal/PortalAccountSection.tsx +61 -0
- package/src/ui/styled/portal/PortalActivity.tsx +167 -0
- package/src/ui/styled/portal/PortalBilling.tsx +197 -0
- package/src/ui/styled/portal/PortalFiles.tsx +279 -0
- package/src/ui/styled/portal/PortalHome.tsx +401 -0
- package/src/ui/styled/portal/PortalMessages.tsx +300 -0
- package/src/ui/styled/portal/PortalPreviewBanner.tsx +75 -0
- package/src/ui/styled/portal/PortalProjects.tsx +407 -0
- package/src/ui/styled/portal/PortalShell.tsx +397 -0
- package/src/ui/styled/portal/_tests/PortalAppHome.spec.tsx +90 -0
- package/src/ui/styled/portal/_tests/PortalFiles.spec.tsx +159 -0
- package/src/ui/styled/portal/_tests/PortalHome.spec.tsx +148 -0
- package/src/ui/styled/portal/_tests/PortalMessages.spec.tsx +168 -0
- package/src/ui/styled/portal/_tests/PortalPreviewBanner.spec.tsx +132 -0
- package/src/ui/styled/portal/_tests/portalNav.spec.ts +123 -0
- package/src/ui/styled/portal/index.ts +13 -0
- package/src/ui/styled/portal/portalNav.ts +260 -0
- package/src/ui/styled/portal/shared.tsx +206 -0
- package/src/ui/styled/work/WorkInviteAccept.tsx +228 -168
- package/src/ui/styled/work/WorkPortalProjects.tsx +68 -120
- package/src/ui/styled/work/WorkProjectDocuments.tsx +99 -128
- package/src/ui/styled/work/WorkProjectInvoices.tsx +91 -86
- package/src/ui/styled/work/WorkProjectReport.tsx +11 -17
- package/src/ui/styled/work/WorkReportBody.tsx +223 -248
- package/src/ui/styled/work/WorkTaskAttachments.tsx +23 -29
- package/src/ui/styled/work/WorkTaskComments.tsx +75 -102
- package/src/ui/styled/work/WorkTaskDetail.tsx +48 -59
- package/src/ui/styled/work/WorkTokenReport.tsx +8 -16
- package/src/ui/styled/work/index.ts +1 -1
- package/src/utils/_tests/landing.spec.ts +39 -0
- package/src/utils/_tests/metaEvents.spec.ts +264 -0
- package/src/utils/_tests/structuredDataSeo.spec.ts +247 -0
- package/src/utils/landing.ts +36 -10
- package/src/utils/metaEvents.ts +302 -0
- package/src/utils/structuredData.ts +339 -1
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Countries for the member directory and map: every ISO 3166-1 alpha-2 code a
|
|
3
|
+
* member may pick, a name for it in the viewer's language (through
|
|
4
|
+
* `Intl.DisplayNames`, so no name list ships), and an approximate centre per
|
|
5
|
+
* country for placing a dot on the member globe.
|
|
6
|
+
*
|
|
7
|
+
* The centres began as the admin analytics globe's table
|
|
8
|
+
* (apps/admin/.../analytics/-components/country-centroids.ts), which Forge
|
|
9
|
+
* cannot import, extended to most countries. They place a country-level dot and
|
|
10
|
+
* nothing finer: members choose a country, never a location. A country missing
|
|
11
|
+
* a centre still lists in the directory, it just has no dot.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** Every ISO 3166-1 alpha-2 code, as a member can choose one. */
|
|
15
|
+
// prettier-ignore
|
|
16
|
+
export const COUNTRY_CODES: readonly string[] = [
|
|
17
|
+
"AD", "AE", "AF", "AG", "AI", "AL", "AM", "AO", "AQ", "AR", "AS", "AT", "AU", "AW", "AX", "AZ",
|
|
18
|
+
"BA", "BB", "BD", "BE", "BF", "BG", "BH", "BI", "BJ", "BL", "BM", "BN", "BO", "BQ", "BR", "BS",
|
|
19
|
+
"BT", "BV", "BW", "BY", "BZ", "CA", "CC", "CD", "CF", "CG", "CH", "CI", "CK", "CL", "CM", "CN",
|
|
20
|
+
"CO", "CR", "CU", "CV", "CW", "CX", "CY", "CZ", "DE", "DJ", "DK", "DM", "DO", "DZ", "EC", "EE",
|
|
21
|
+
"EG", "EH", "ER", "ES", "ET", "FI", "FJ", "FK", "FM", "FO", "FR", "GA", "GB", "GD", "GE", "GF",
|
|
22
|
+
"GG", "GH", "GI", "GL", "GM", "GN", "GP", "GQ", "GR", "GS", "GT", "GU", "GW", "GY", "HK", "HM",
|
|
23
|
+
"HN", "HR", "HT", "HU", "ID", "IE", "IL", "IM", "IN", "IO", "IQ", "IR", "IS", "IT", "JE", "JM",
|
|
24
|
+
"JO", "JP", "KE", "KG", "KH", "KI", "KM", "KN", "KP", "KR", "KW", "KY", "KZ", "LA", "LB", "LC",
|
|
25
|
+
"LI", "LK", "LR", "LS", "LT", "LU", "LV", "LY", "MA", "MC", "MD", "ME", "MF", "MG", "MH", "MK",
|
|
26
|
+
"ML", "MM", "MN", "MO", "MP", "MQ", "MR", "MS", "MT", "MU", "MV", "MW", "MX", "MY", "MZ", "NA",
|
|
27
|
+
"NC", "NE", "NF", "NG", "NI", "NL", "NO", "NP", "NR", "NU", "NZ", "OM", "PA", "PE", "PF", "PG",
|
|
28
|
+
"PH", "PK", "PL", "PM", "PN", "PR", "PS", "PT", "PW", "PY", "QA", "RE", "RO", "RS", "RU", "RW",
|
|
29
|
+
"SA", "SB", "SC", "SD", "SE", "SG", "SH", "SI", "SJ", "SK", "SL", "SM", "SN", "SO", "SR", "SS",
|
|
30
|
+
"ST", "SV", "SX", "SY", "SZ", "TC", "TD", "TF", "TG", "TH", "TJ", "TK", "TL", "TM", "TN", "TO",
|
|
31
|
+
"TR", "TT", "TV", "TW", "TZ", "UA", "UG", "UM", "US", "UY", "UZ", "VA", "VC", "VE", "VG", "VI",
|
|
32
|
+
"VN", "VU", "WF", "WS", "YE", "YT", "ZA", "ZM", "ZW",
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
/** Approximate centre of each country, alpha-2 to [latitude, longitude]. */
|
|
36
|
+
// prettier-ignore
|
|
37
|
+
export const COUNTRY_CENTROIDS: Readonly<Record<string, readonly [number, number]>> = {
|
|
38
|
+
AD: [42.5, 1.6], AE: [23.4, 53.8], AF: [33.9, 67.7], AG: [17.1, -61.8], AL: [41.2, 20.2],
|
|
39
|
+
AM: [40.1, 45.0], AO: [-11.2, 17.9], AR: [-38.4, -63.6], AT: [47.5, 14.6], AU: [-25.3, 133.8],
|
|
40
|
+
AZ: [40.1, 47.6], BA: [43.9, 17.7], BB: [13.2, -59.5], BD: [23.7, 90.4], BE: [50.5, 4.5],
|
|
41
|
+
BF: [12.2, -1.6], BG: [42.7, 25.5], BH: [26.0, 50.6], BI: [-3.4, 29.9], BJ: [9.3, 2.3],
|
|
42
|
+
BN: [4.5, 114.7], BO: [-16.3, -63.6], BR: [-14.2, -51.9], BS: [25.0, -77.4], BT: [27.5, 90.4],
|
|
43
|
+
BW: [-22.3, 24.7], BY: [53.7, 27.9], BZ: [17.2, -88.5], CA: [56.1, -106.3], CD: [-4.0, 21.8],
|
|
44
|
+
CF: [6.6, 20.9], CG: [-0.2, 15.8], CH: [46.8, 8.2], CI: [7.5, -5.5], CL: [-35.7, -71.5],
|
|
45
|
+
CM: [7.4, 12.4], CN: [35.9, 104.2], CO: [4.6, -74.3], CR: [9.7, -83.8], CU: [21.5, -77.8],
|
|
46
|
+
CV: [16.0, -24.0], CY: [35.1, 33.4], CZ: [49.8, 15.5], DE: [51.2, 10.5], DJ: [11.8, 42.6],
|
|
47
|
+
DK: [56.3, 9.5], DM: [15.4, -61.4], DO: [18.7, -70.2], DZ: [28.0, 1.7], EC: [-1.8, -78.2],
|
|
48
|
+
EE: [58.6, 25.0], EG: [26.8, 30.8], ER: [15.2, 39.8], ES: [40.5, -3.7], ET: [9.1, 40.5],
|
|
49
|
+
FI: [61.9, 25.7], FJ: [-17.7, 178.1], FR: [46.2, 2.2], GA: [-0.8, 11.6], GB: [55.4, -3.4],
|
|
50
|
+
GD: [12.1, -61.7], GE: [42.3, 43.4], GH: [7.9, -1.0], GL: [71.7, -42.6], GM: [13.4, -15.3],
|
|
51
|
+
GN: [9.9, -9.7], GQ: [1.7, 10.3], GR: [39.1, 21.8], GT: [15.8, -90.2], GW: [11.8, -15.2],
|
|
52
|
+
GY: [4.9, -58.9], HK: [22.3, 114.2], HN: [15.2, -86.2], HR: [45.1, 15.2], HT: [18.97, -72.3],
|
|
53
|
+
HU: [47.2, 19.5], ID: [-0.8, 113.9], IE: [53.4, -8.2], IL: [31.0, 34.9], IN: [20.6, 79.0],
|
|
54
|
+
IQ: [33.2, 43.7], IR: [32.4, 53.7], IS: [65.0, -19.0], IT: [41.9, 12.6], JM: [18.1, -77.3],
|
|
55
|
+
JO: [30.6, 36.2], JP: [36.2, 138.3], KE: [-0.02, 37.9], KG: [41.2, 74.8], KH: [12.6, 104.99],
|
|
56
|
+
KM: [-11.9, 43.9], KN: [17.4, -62.8], KP: [40.3, 127.5], KR: [35.9, 127.8], KW: [29.3, 47.5],
|
|
57
|
+
KZ: [48.0, 66.9], LA: [19.9, 102.5], LB: [33.9, 35.9], LC: [13.9, -60.98], LI: [47.2, 9.6],
|
|
58
|
+
LK: [7.9, 80.8], LR: [6.4, -9.4], LS: [-29.6, 28.2], LT: [55.2, 23.9], LU: [49.8, 6.1],
|
|
59
|
+
LV: [56.9, 24.6], LY: [26.3, 17.2], MA: [31.8, -7.1], MC: [43.7, 7.4], MD: [47.4, 28.4],
|
|
60
|
+
ME: [42.7, 19.4], MG: [-18.8, 46.9], MK: [41.6, 21.7], ML: [17.6, -4.0], MM: [21.9, 95.96],
|
|
61
|
+
MN: [46.9, 103.8], MO: [22.2, 113.5], MR: [21.0, -10.9], MT: [35.9, 14.4], MU: [-20.3, 57.6],
|
|
62
|
+
MV: [3.2, 73.2], MW: [-13.3, 34.3], MX: [23.6, -102.6], MY: [4.2, 101.9], MZ: [-18.7, 35.5],
|
|
63
|
+
NA: [-22.9, 18.5], NE: [17.6, 8.1], NG: [9.1, 8.7], NI: [12.9, -85.2], NL: [52.1, 5.3],
|
|
64
|
+
NO: [60.5, 8.5], NP: [28.4, 84.1], NZ: [-40.9, 174.9], OM: [21.5, 55.9], PA: [8.5, -80.8],
|
|
65
|
+
PE: [-9.2, -75.0], PG: [-6.3, 143.96], PH: [12.9, 121.8], PK: [30.4, 69.3], PL: [51.9, 19.1],
|
|
66
|
+
PR: [18.2, -66.6], PS: [31.95, 35.2], PT: [39.4, -8.2], PY: [-23.4, -58.4], QA: [25.4, 51.2],
|
|
67
|
+
RO: [45.9, 25.0], RS: [44.0, 21.0], RU: [61.5, 105.3], RW: [-1.9, 29.9], SA: [23.9, 45.1],
|
|
68
|
+
SB: [-9.6, 160.2], SC: [-4.7, 55.5], SD: [12.9, 30.2], SE: [60.1, 18.6], SG: [1.35, 103.8],
|
|
69
|
+
SI: [46.2, 15.0], SK: [48.7, 19.7], SL: [8.5, -11.8], SM: [43.9, 12.5], SN: [14.5, -14.5],
|
|
70
|
+
SO: [5.2, 46.2], SR: [3.9, -56.0], SS: [6.9, 31.3], SV: [13.8, -88.9], SY: [34.8, 39.0],
|
|
71
|
+
SZ: [-26.5, 31.5], TD: [15.5, 18.7], TG: [8.6, 0.8], TH: [15.9, 100.99], TJ: [38.9, 71.3],
|
|
72
|
+
TL: [-8.9, 125.7], TM: [38.97, 59.6], TN: [33.9, 9.5], TO: [-21.2, -175.2], TR: [38.96, 35.2],
|
|
73
|
+
TT: [10.7, -61.2], TW: [23.7, 121.0], TZ: [-6.4, 34.9], UA: [48.4, 31.2], UG: [1.4, 32.3],
|
|
74
|
+
US: [37.1, -95.7], UY: [-32.5, -55.8], UZ: [41.4, 64.6], VC: [13.3, -61.2], VE: [6.4, -66.6],
|
|
75
|
+
VN: [14.1, 108.3], VU: [-15.4, 166.96], WS: [-13.8, -172.1], YE: [15.6, 48.5], ZA: [-30.6, 22.9],
|
|
76
|
+
ZM: [-13.1, 27.8], ZW: [-19.0, 29.2],
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/** True for a two-letter code this list knows. */
|
|
80
|
+
export function isCountryCode(code: unknown): code is string {
|
|
81
|
+
return typeof code === "string" && COUNTRY_CODES.includes(code.toUpperCase());
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const displayNamesCache = new Map<string, Intl.DisplayNames | null>();
|
|
85
|
+
|
|
86
|
+
function displayNames(locale: string): Intl.DisplayNames | null {
|
|
87
|
+
if (!displayNamesCache.has(locale)) {
|
|
88
|
+
let names: Intl.DisplayNames | null = null;
|
|
89
|
+
try {
|
|
90
|
+
names =
|
|
91
|
+
typeof Intl !== "undefined" && "DisplayNames" in Intl
|
|
92
|
+
? new Intl.DisplayNames([locale], { type: "region" })
|
|
93
|
+
: null;
|
|
94
|
+
} catch {
|
|
95
|
+
names = null;
|
|
96
|
+
}
|
|
97
|
+
displayNamesCache.set(locale, names);
|
|
98
|
+
}
|
|
99
|
+
return displayNamesCache.get(locale) ?? null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* A country's name in `locale` ("DE" is "Germany" in English, "Deutschland"
|
|
104
|
+
* in German). Falls back to the code where the runtime has no names, and to
|
|
105
|
+
* null for no code at all.
|
|
106
|
+
*/
|
|
107
|
+
export function countryName(code: string | null | undefined, locale = "en"): string | null {
|
|
108
|
+
if (!code) return null;
|
|
109
|
+
const upper = code.toUpperCase();
|
|
110
|
+
try {
|
|
111
|
+
return displayNames(locale)?.of(upper) ?? upper;
|
|
112
|
+
} catch {
|
|
113
|
+
return upper;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Every country as a select option, sorted by its name in `locale`. */
|
|
118
|
+
export function countryOptions(locale = "en"): { code: string; name: string }[] {
|
|
119
|
+
const collator = new Intl.Collator(locale);
|
|
120
|
+
return COUNTRY_CODES.map((code) => ({ code, name: countryName(code, locale) ?? code })).sort((a, b) =>
|
|
121
|
+
collator.compare(a.name, b.name),
|
|
122
|
+
);
|
|
123
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Community headless primitives. NOTE: this is a lane-local aggregator
|
|
1
|
+
// Community headless primitives. NOTE: this is a lane-local aggregator. The
|
|
2
2
|
// integrator re-exports it from `ui/headless/index.ts` (`export * from "./community"`).
|
|
3
3
|
export {
|
|
4
4
|
buildLexicalState,
|
|
@@ -18,3 +18,83 @@ export { useRichMentionComposer, type RichCommand } from "./useRichMentionCompos
|
|
|
18
18
|
export { serializeRichToLexical, collectMentions, isRichEmpty } from "./richLexical";
|
|
19
19
|
export { useCommunitySpaceFeed } from "./useCommunitySpaceFeed";
|
|
20
20
|
export { useCommunityThread } from "./useCommunityThread";
|
|
21
|
+
export {
|
|
22
|
+
useCommunityAgentThinking,
|
|
23
|
+
useChatAgentThinking,
|
|
24
|
+
useCommunityAgentAnswerFeedback,
|
|
25
|
+
useCommunityCatchUp,
|
|
26
|
+
linkifyText,
|
|
27
|
+
useCommunityAgentAnswerRatings,
|
|
28
|
+
chatThinkingKey,
|
|
29
|
+
AGENT_THINKING_TIMEOUT_MS,
|
|
30
|
+
CATCH_ME_UP_MIN_REPLIES,
|
|
31
|
+
type LinkifiedSegment,
|
|
32
|
+
} from "./useCommunityAgent";
|
|
33
|
+
export {
|
|
34
|
+
COUNTRY_CODES,
|
|
35
|
+
COUNTRY_CENTROIDS,
|
|
36
|
+
countryName,
|
|
37
|
+
countryOptions,
|
|
38
|
+
isCountryCode,
|
|
39
|
+
} from "./countries";
|
|
40
|
+
export {
|
|
41
|
+
useCommunityMemberDirectory,
|
|
42
|
+
memberGlobePoints,
|
|
43
|
+
memberMapCountries,
|
|
44
|
+
memberGlobeMarkers,
|
|
45
|
+
memberLocationLabel,
|
|
46
|
+
globeAnglesFor,
|
|
47
|
+
DIRECTORY_SORTS,
|
|
48
|
+
DIRECTORY_LEVELS,
|
|
49
|
+
DIRECTORY_SEARCH_DEBOUNCE_MS,
|
|
50
|
+
type MemberGlobePoint,
|
|
51
|
+
type MemberGlobeMarker,
|
|
52
|
+
type CommunityMembersView,
|
|
53
|
+
type UseCommunityMemberDirectoryOptions,
|
|
54
|
+
} from "./useCommunityMembers";
|
|
55
|
+
export {
|
|
56
|
+
useCommunityModeration,
|
|
57
|
+
useCommunityModeratorActions,
|
|
58
|
+
describeHoldReason,
|
|
59
|
+
describeModerationNotification,
|
|
60
|
+
restorableLogEntries,
|
|
61
|
+
communityMuteUntil,
|
|
62
|
+
moderationErrorMessage,
|
|
63
|
+
COMMUNITY_MUTE_DAYS,
|
|
64
|
+
type CommunityHoldReason,
|
|
65
|
+
type CommunityMuteDays,
|
|
66
|
+
type CommunityModerationOutcome,
|
|
67
|
+
type CommunityLogRestoreTarget,
|
|
68
|
+
type CommunityReportResolution,
|
|
69
|
+
type UseCommunityModerationOptions,
|
|
70
|
+
} from "./useCommunityModeration";
|
|
71
|
+
export {
|
|
72
|
+
useCommunityPoll,
|
|
73
|
+
useCommunityPollDraft,
|
|
74
|
+
validatePollDraft,
|
|
75
|
+
buildPollPayload,
|
|
76
|
+
emptyPollDraft,
|
|
77
|
+
parsePollCloseTime,
|
|
78
|
+
pollOptionPercent,
|
|
79
|
+
pollHiddenReason,
|
|
80
|
+
pollVoteErrorCode,
|
|
81
|
+
sameAnswer,
|
|
82
|
+
COMMUNITY_POLL_LIMITS,
|
|
83
|
+
type CommunityPollDraft,
|
|
84
|
+
type CommunityPollDraftErrors,
|
|
85
|
+
type CommunityPollQuestionError,
|
|
86
|
+
type CommunityPollOptionError,
|
|
87
|
+
type CommunityPollOptionsError,
|
|
88
|
+
type CommunityPollClosesError,
|
|
89
|
+
type CommunityPollHiddenReason,
|
|
90
|
+
type CommunityPollVoteError,
|
|
91
|
+
} from "./communityPoll";
|
|
92
|
+
export {
|
|
93
|
+
COMMUNITY_EMBED_ORIGINS,
|
|
94
|
+
safeHttpUrl,
|
|
95
|
+
safeEmbedUrl,
|
|
96
|
+
linkPreviewHost,
|
|
97
|
+
linkPreviewDisplay,
|
|
98
|
+
autoplayEmbedUrl,
|
|
99
|
+
type LinkPreviewDisplay,
|
|
100
|
+
} from "./linkPreview";
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { CommunityLinkPreview } from "../../../data/queries/useCommunity";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Link cards on community posts (community parity §6.2). The server puts the
|
|
5
|
+
* first link of a post on it as a card, shortly after it is published. These
|
|
6
|
+
* helpers decide what of that card is safe to render: an http(s) link only,
|
|
7
|
+
* and a video player only from the three privacy-respecting embed origins the
|
|
8
|
+
* server itself builds. Anything else falls back to a plain link card, and a
|
|
9
|
+
* preview with no safe link renders nothing at all.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** The only origins a post's video may play from. */
|
|
13
|
+
export const COMMUNITY_EMBED_ORIGINS: readonly string[] = [
|
|
14
|
+
"https://www.youtube-nocookie.com",
|
|
15
|
+
"https://player.vimeo.com",
|
|
16
|
+
"https://www.loom.com",
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
/** The address, when it is an absolute http(s) URL. */
|
|
20
|
+
export function safeHttpUrl(value: unknown): string | null {
|
|
21
|
+
if (typeof value !== "string" || !value.trim()) return null;
|
|
22
|
+
try {
|
|
23
|
+
const url = new URL(value.trim());
|
|
24
|
+
return url.protocol === "https:" || url.protocol === "http:" ? url.toString() : null;
|
|
25
|
+
} catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The video's player address, when the preview is a video from an allowed origin. */
|
|
31
|
+
export function safeEmbedUrl(preview: Pick<CommunityLinkPreview, "kind" | "embedUrl"> | null | undefined): string | null {
|
|
32
|
+
if (!preview || preview.kind !== "video" || typeof preview.embedUrl !== "string") return null;
|
|
33
|
+
try {
|
|
34
|
+
const url = new URL(preview.embedUrl);
|
|
35
|
+
return url.protocol === "https:" && COMMUNITY_EMBED_ORIGINS.includes(url.origin) ? url.toString() : null;
|
|
36
|
+
} catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** "example.com": the host without `www.`, for a card with no site name. */
|
|
42
|
+
export function linkPreviewHost(value: string | null | undefined): string | null {
|
|
43
|
+
const href = safeHttpUrl(value);
|
|
44
|
+
if (!href) return null;
|
|
45
|
+
return new URL(href).hostname.replace(/^www\./, "") || null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** How a post's preview renders: a video facade, a link card, or nothing. */
|
|
49
|
+
export type LinkPreviewDisplay =
|
|
50
|
+
| { kind: "video"; href: string; embedUrl: string; imageUrl: string | null }
|
|
51
|
+
| { kind: "card"; href: string; imageUrl: string | null }
|
|
52
|
+
| null;
|
|
53
|
+
|
|
54
|
+
export function linkPreviewDisplay(preview: CommunityLinkPreview | null | undefined): LinkPreviewDisplay {
|
|
55
|
+
if (!preview) return null;
|
|
56
|
+
const href = safeHttpUrl(preview.url);
|
|
57
|
+
if (!href) return null;
|
|
58
|
+
const imageUrl = safeHttpUrl(preview.imageUrl);
|
|
59
|
+
const embedUrl = safeEmbedUrl(preview);
|
|
60
|
+
return embedUrl ? { kind: "video", href, embedUrl, imageUrl } : { kind: "card", href, imageUrl };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The player address with autoplay on, used once the member has pressed play,
|
|
65
|
+
* so one click starts the video rather than showing a second play button.
|
|
66
|
+
*/
|
|
67
|
+
export function autoplayEmbedUrl(embedUrl: string): string {
|
|
68
|
+
const url = new URL(embedUrl);
|
|
69
|
+
url.searchParams.set("autoplay", "1");
|
|
70
|
+
return url.toString();
|
|
71
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// DOM → Lexical-state serializer for the rich community composer. The
|
|
2
2
|
// contentEditable div is the source of truth; here we walk its DOM and emit the
|
|
3
3
|
// exact Lexical JSON shapes CommunityRichText renders and the create-post API
|
|
4
|
-
// stores
|
|
4
|
+
// stores, WITHOUT pulling the Lexical runtime into forge. Mirrors the node
|
|
5
5
|
// shapes in ./lexical.ts (buildLexicalState) so plain-text and rich content
|
|
6
6
|
// round-trip through the same renderer.
|
|
7
7
|
|
|
@@ -41,6 +41,23 @@ const mentionNode = (accountId: string, name: string): LexicalNodeJson => ({
|
|
|
41
41
|
style: "",
|
|
42
42
|
});
|
|
43
43
|
|
|
44
|
+
/**
|
|
45
|
+
* A mention of the community AI. It carries `agentId` and NO account field: the
|
|
46
|
+
* backend reads an account from `accountId | id | mentionId`, and the agent has
|
|
47
|
+
* no account, so it would otherwise be read as a member mention.
|
|
48
|
+
*/
|
|
49
|
+
const agentMentionNode = (agentId: string, name: string): LexicalNodeJson => ({
|
|
50
|
+
type: "mention",
|
|
51
|
+
version: 1,
|
|
52
|
+
agentId,
|
|
53
|
+
mentionName: name,
|
|
54
|
+
text: `@${name}`,
|
|
55
|
+
detail: 0,
|
|
56
|
+
format: 0,
|
|
57
|
+
mode: "segmented",
|
|
58
|
+
style: "",
|
|
59
|
+
});
|
|
60
|
+
|
|
44
61
|
const linebreakNode = (): LexicalNodeJson => ({ type: "linebreak", version: 1 });
|
|
45
62
|
|
|
46
63
|
const linkNode = (url: string, children: LexicalNodeJson[]): LexicalNodeJson => ({
|
|
@@ -177,6 +194,14 @@ function serializeInline(nodes: NodeListOf<ChildNode> | ChildNode[], inherited:
|
|
|
177
194
|
return;
|
|
178
195
|
}
|
|
179
196
|
|
|
197
|
+
// The community AI's chip: a non-editable span carrying the agent id.
|
|
198
|
+
const agentId = node.getAttribute("data-agent-id");
|
|
199
|
+
if (agentId) {
|
|
200
|
+
const name = (node.getAttribute("data-mention-name") ?? node.textContent ?? "").replace(/^@/, "");
|
|
201
|
+
push(agentMentionNode(agentId, name));
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
|
|
180
205
|
// Mention chip: a non-editable span carrying the account id.
|
|
181
206
|
const accountId = node.getAttribute("data-account-id");
|
|
182
207
|
if (accountId) {
|
|
@@ -309,6 +334,6 @@ export function collectMentions(root: HTMLElement): MentionRef[] {
|
|
|
309
334
|
|
|
310
335
|
/** Whether the editor holds any real content (text or a mention chip). */
|
|
311
336
|
export function isRichEmpty(root: HTMLElement): boolean {
|
|
312
|
-
if (root.querySelector("[data-account-id]")) return false;
|
|
337
|
+
if (root.querySelector("[data-account-id], [data-agent-id]")) return false;
|
|
313
338
|
return (root.textContent ?? "").trim().length === 0;
|
|
314
339
|
}
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import type { Socket } from "socket.io-client";
|
|
3
|
+
import {
|
|
4
|
+
communityAgentSummaryError,
|
|
5
|
+
useCommunityAgentFeedback,
|
|
6
|
+
useCommunityAgentRatings,
|
|
7
|
+
useCommunityAgentSummary,
|
|
8
|
+
type CommunityAgentFeedbackEntity,
|
|
9
|
+
type CommunityAgentFeedbackValue,
|
|
10
|
+
type CommunityAgentSummary,
|
|
11
|
+
type CommunityAgentSummaryError,
|
|
12
|
+
} from "../../../data/queries/useCommunityAgent";
|
|
13
|
+
import type { CommunityReply } from "../../../data/queries/useCommunity";
|
|
14
|
+
import type { ChatMessage } from "../../../data/queries/useChat";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* How long a "thinking" indicator may stand without its `done`. The answer job
|
|
18
|
+
* always sends `done`, but a dropped socket would otherwise leave the
|
|
19
|
+
* indicator up for ever.
|
|
20
|
+
*/
|
|
21
|
+
export const AGENT_THINKING_TIMEOUT_MS = 120_000;
|
|
22
|
+
|
|
23
|
+
/** The reply count from which a thread offers "Catch me up". */
|
|
24
|
+
export const CATCH_ME_UP_MIN_REPLIES = 10;
|
|
25
|
+
|
|
26
|
+
// ── Linkified text ───────────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
export type LinkifiedSegment = { kind: "text"; text: string } | { kind: "link"; text: string; url: string };
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A titled link, `[Title](https://...)`, or a bare http(s) address. The titled
|
|
32
|
+
* form is how the agent writes its sources; bare addresses are kept for
|
|
33
|
+
* answers written before it, and for addresses inside the answer itself.
|
|
34
|
+
*/
|
|
35
|
+
const LINK_RE = /\[([^\]\n]+)\]\((https?:\/\/[^\s)]+)\)|https?:\/\/[^\s<>"')\]]+/g;
|
|
36
|
+
/** Punctuation that ends a sentence rather than the address. */
|
|
37
|
+
const TRAILING_PUNCTUATION = /[.,;:!?]+$/;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Split plain text into text and http(s) link segments. The agent writes its
|
|
41
|
+
* answers as plain text with a "Sources:" list of titled links; this is what
|
|
42
|
+
* makes them clickable without trusting any markup. Only http(s) addresses
|
|
43
|
+
* ever become links. Pure.
|
|
44
|
+
*/
|
|
45
|
+
export function linkifyText(text: string): LinkifiedSegment[] {
|
|
46
|
+
const out: LinkifiedSegment[] = [];
|
|
47
|
+
let last = 0;
|
|
48
|
+
for (const match of text.matchAll(LINK_RE)) {
|
|
49
|
+
const start = match.index ?? 0;
|
|
50
|
+
if (start > last) out.push({ kind: "text", text: text.slice(last, start) });
|
|
51
|
+
if (match[1] !== undefined && match[2] !== undefined) {
|
|
52
|
+
out.push({ kind: "link", text: match[1], url: match[2] });
|
|
53
|
+
last = start + match[0].length;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
let url = match[0];
|
|
57
|
+
const trailing = TRAILING_PUNCTUATION.exec(url)?.[0] ?? "";
|
|
58
|
+
if (trailing) url = url.slice(0, -trailing.length);
|
|
59
|
+
out.push({ kind: "link", text: url, url });
|
|
60
|
+
last = start + url.length;
|
|
61
|
+
}
|
|
62
|
+
if (last < text.length) out.push({ kind: "text", text: text.slice(last) });
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ── Who asked ────────────────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The viewer's ratings on the AI answers in one visible list, and with them
|
|
70
|
+
* who is offered thumbs at all: the read returns ONLY the answers this account
|
|
71
|
+
* asked for, so an answer is ratable exactly when its id came back. Pass the
|
|
72
|
+
* ids of the agent's answers in the list; nothing is asked for when there
|
|
73
|
+
* are none, when signed out, or when the AI is off.
|
|
74
|
+
*/
|
|
75
|
+
export function useCommunityAgentAnswerRatings(entityType: CommunityAgentFeedbackEntity, answerIds: string[]) {
|
|
76
|
+
const { data } = useCommunityAgentRatings(entityType, answerIds);
|
|
77
|
+
const byId = useMemo(() => new Map((data ?? []).map((row) => [row.entityId, row.feedback])), [data]);
|
|
78
|
+
return {
|
|
79
|
+
/** The viewer asked this question, so they may rate the answer. */
|
|
80
|
+
canRate: (entityId: string) => byId.has(entityId),
|
|
81
|
+
/** Their saved rating, or null. */
|
|
82
|
+
ratingOf: (entityId: string): 1 | -1 | null => byId.get(entityId) ?? null,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ── Thinking ────────────────────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
interface SocketEnvelope {
|
|
89
|
+
event?: string;
|
|
90
|
+
data?: Record<string, unknown> & {
|
|
91
|
+
postId?: string;
|
|
92
|
+
state?: string;
|
|
93
|
+
reply?: CommunityReply;
|
|
94
|
+
channelId?: string;
|
|
95
|
+
parentMessageId?: string | null;
|
|
96
|
+
message?: ChatMessage;
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** A set of keys that each clear themselves after `AGENT_THINKING_TIMEOUT_MS`. */
|
|
101
|
+
function useExpiringSet() {
|
|
102
|
+
const [keys, setKeys] = useState<ReadonlySet<string>>(() => new Set());
|
|
103
|
+
const timers = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
|
|
104
|
+
|
|
105
|
+
const remove = useCallback((key: string) => {
|
|
106
|
+
const timer = timers.current.get(key);
|
|
107
|
+
if (timer) clearTimeout(timer);
|
|
108
|
+
timers.current.delete(key);
|
|
109
|
+
setKeys((prev) => {
|
|
110
|
+
if (!prev.has(key)) return prev;
|
|
111
|
+
const next = new Set(prev);
|
|
112
|
+
next.delete(key);
|
|
113
|
+
return next;
|
|
114
|
+
});
|
|
115
|
+
}, []);
|
|
116
|
+
|
|
117
|
+
const add = useCallback(
|
|
118
|
+
(key: string) => {
|
|
119
|
+
const timer = timers.current.get(key);
|
|
120
|
+
if (timer) clearTimeout(timer);
|
|
121
|
+
timers.current.set(
|
|
122
|
+
key,
|
|
123
|
+
setTimeout(() => remove(key), AGENT_THINKING_TIMEOUT_MS),
|
|
124
|
+
);
|
|
125
|
+
setKeys((prev) => (prev.has(key) ? prev : new Set(prev).add(key)));
|
|
126
|
+
},
|
|
127
|
+
[remove],
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
useEffect(() => {
|
|
131
|
+
const map = timers.current;
|
|
132
|
+
return () => {
|
|
133
|
+
map.forEach((timer) => clearTimeout(timer));
|
|
134
|
+
map.clear();
|
|
135
|
+
};
|
|
136
|
+
}, []);
|
|
137
|
+
|
|
138
|
+
return { keys, add, remove };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Which posts the community AI is answering right now, from the space room's
|
|
143
|
+
* `communityAgentThinking` events (the room is joined by the feed or thread
|
|
144
|
+
* hook). An entry clears on `done`, when the answer itself arrives as a
|
|
145
|
+
* `communityNewReply`, or after `AGENT_THINKING_TIMEOUT_MS`.
|
|
146
|
+
*
|
|
147
|
+
* `accepts` limits it to the posts the caller shows (one post in a thread).
|
|
148
|
+
*/
|
|
149
|
+
export function useCommunityAgentThinking(socket: Socket | null, accepts?: (postId: string) => boolean) {
|
|
150
|
+
const { keys, add, remove } = useExpiringSet();
|
|
151
|
+
const acceptsRef = useRef(accepts);
|
|
152
|
+
acceptsRef.current = accepts;
|
|
153
|
+
|
|
154
|
+
useEffect(() => {
|
|
155
|
+
if (!socket) return;
|
|
156
|
+
const onMessage = (envelope: SocketEnvelope) => {
|
|
157
|
+
const postId = envelope.data?.postId;
|
|
158
|
+
if (!postId || (acceptsRef.current && !acceptsRef.current(postId))) return;
|
|
159
|
+
if (envelope.event === "communityAgentThinking") {
|
|
160
|
+
if (envelope.data?.state === "thinking") add(postId);
|
|
161
|
+
else remove(postId);
|
|
162
|
+
}
|
|
163
|
+
if (envelope.event === "communityNewReply" && envelope.data?.reply?.agentAuthor) remove(postId);
|
|
164
|
+
};
|
|
165
|
+
socket.on("message", onMessage);
|
|
166
|
+
return () => {
|
|
167
|
+
socket.off("message", onMessage);
|
|
168
|
+
};
|
|
169
|
+
}, [socket, add, remove]);
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
thinkingPostIds: keys,
|
|
173
|
+
isThinking: (postId: string) => keys.has(postId),
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** The key one chat thinking indicator lives under: the thread, or the channel itself. */
|
|
178
|
+
export const chatThinkingKey = (channelId: string, parentMessageId: string | null | undefined) =>
|
|
179
|
+
`${channelId}:${parentMessageId ?? ""}`;
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Where the community AI is answering in chat, from `chatAgentThinking` on the
|
|
183
|
+
* channel rooms the chat joined. A channel answer goes in the tagging
|
|
184
|
+
* message's thread (`parentMessageId` set); a DM answer goes in the DM itself
|
|
185
|
+
* (`parentMessageId` null). Clears on `done`, on the agent's message arriving,
|
|
186
|
+
* or after `AGENT_THINKING_TIMEOUT_MS`.
|
|
187
|
+
*/
|
|
188
|
+
export function useChatAgentThinking(socket: Socket | null) {
|
|
189
|
+
const { keys, add, remove } = useExpiringSet();
|
|
190
|
+
|
|
191
|
+
useEffect(() => {
|
|
192
|
+
if (!socket) return;
|
|
193
|
+
const onMessage = (envelope: SocketEnvelope) => {
|
|
194
|
+
const channelId = envelope.data?.channelId;
|
|
195
|
+
if (!channelId) return;
|
|
196
|
+
if (envelope.event === "chatAgentThinking") {
|
|
197
|
+
const key = chatThinkingKey(channelId, envelope.data?.parentMessageId ?? null);
|
|
198
|
+
if (envelope.data?.state === "thinking") add(key);
|
|
199
|
+
else remove(key);
|
|
200
|
+
}
|
|
201
|
+
const message = envelope.data?.message;
|
|
202
|
+
if (envelope.event === "chatNewMessage" && message?.senderAgentId) {
|
|
203
|
+
remove(chatThinkingKey(channelId, message.parentMessageId ?? null));
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
socket.on("message", onMessage);
|
|
207
|
+
return () => {
|
|
208
|
+
socket.off("message", onMessage);
|
|
209
|
+
};
|
|
210
|
+
}, [socket, add, remove]);
|
|
211
|
+
|
|
212
|
+
return {
|
|
213
|
+
isThinking: (channelId: string | null | undefined, parentMessageId: string | null | undefined) =>
|
|
214
|
+
!!channelId && keys.has(chatThinkingKey(channelId, parentMessageId ?? null)),
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ── Feedback ─────────────────────────────────────────────────────────────────
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Thumbs up / down on one AI answer, optimistic. `saved` is the rating the
|
|
222
|
+
* ratings read hydrated, so the thumbs survive a reload; a press shows at once,
|
|
223
|
+
* and a success lands in the ratings cache. Choosing the active thumb again
|
|
224
|
+
* clears it. A failed rating rolls back; a 404 (not the member who asked, a
|
|
225
|
+
* safety net now the ratings read decides who sees the control) hides it.
|
|
226
|
+
*/
|
|
227
|
+
export function useCommunityAgentAnswerFeedback(
|
|
228
|
+
entityType: CommunityAgentFeedbackEntity,
|
|
229
|
+
entityId: string,
|
|
230
|
+
saved: 1 | -1 | null = null,
|
|
231
|
+
) {
|
|
232
|
+
const mutation = useCommunityAgentFeedback();
|
|
233
|
+
// `undefined` = follow the saved rating; set while a press is in flight or failed back.
|
|
234
|
+
const [local, setLocal] = useState<1 | -1 | null | undefined>(undefined);
|
|
235
|
+
const [hidden, setHidden] = useState(false);
|
|
236
|
+
const value = local !== undefined ? local : saved;
|
|
237
|
+
|
|
238
|
+
const rate = useCallback(
|
|
239
|
+
(thumb: 1 | -1) => {
|
|
240
|
+
const previous = value;
|
|
241
|
+
const next: CommunityAgentFeedbackValue = previous === thumb ? 0 : thumb;
|
|
242
|
+
setLocal(next === 0 ? null : next);
|
|
243
|
+
mutation.mutate(
|
|
244
|
+
{ entityType, entityId, value: next },
|
|
245
|
+
{
|
|
246
|
+
onError: (error) => {
|
|
247
|
+
setLocal(previous);
|
|
248
|
+
if ((error as { response?: { status?: number } })?.response?.status === 404) setHidden(true);
|
|
249
|
+
},
|
|
250
|
+
},
|
|
251
|
+
);
|
|
252
|
+
},
|
|
253
|
+
[value, mutation, entityType, entityId],
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
return { value, hidden, rate, isPending: mutation.isPending };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ── Catch me up ──────────────────────────────────────────────────────────────
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* "Catch me up" on a long thread: ask, then show the private summary until the
|
|
263
|
+
* member dismisses it. `error` is `daily_limit` (409), `unavailable` (400: too
|
|
264
|
+
* short, or the AI cannot answer now) or `failed`.
|
|
265
|
+
*/
|
|
266
|
+
export function useCommunityCatchUp(postId?: string) {
|
|
267
|
+
const mutation = useCommunityAgentSummary(postId);
|
|
268
|
+
const [summary, setSummary] = useState<CommunityAgentSummary | null>(null);
|
|
269
|
+
const [error, setError] = useState<CommunityAgentSummaryError | null>(null);
|
|
270
|
+
|
|
271
|
+
const ask = useCallback(() => {
|
|
272
|
+
setError(null);
|
|
273
|
+
mutation.mutate(undefined, {
|
|
274
|
+
onSuccess: (data) => setSummary(data),
|
|
275
|
+
onError: (e) => setError(communityAgentSummaryError(e)),
|
|
276
|
+
});
|
|
277
|
+
}, [mutation]);
|
|
278
|
+
|
|
279
|
+
const dismiss = useCallback(() => {
|
|
280
|
+
setSummary(null);
|
|
281
|
+
setError(null);
|
|
282
|
+
}, []);
|
|
283
|
+
|
|
284
|
+
return { summary, error, ask, dismiss, isPending: mutation.isPending };
|
|
285
|
+
}
|