@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.
- package/package.json +2 -1
- package/src/_tests/testCommunity.tsx +28 -0
- package/src/contexts/CartContext.tsx +34 -1
- package/src/contexts/CommunityContext.tsx +192 -0
- 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 +52 -40
- package/src/data/queries/useCheckouts.ts +2 -1
- package/src/data/queries/useCoachingAvailability.ts +10 -1
- package/src/data/queries/useCommunities.ts +345 -0
- package/src/data/queries/useCommunity.ts +450 -102
- 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/useMembership.ts +32 -7
- package/src/data/queries/useMembershipCalls.ts +116 -7
- 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/usePostCollections.ts +28 -16
- package/src/data/queries/usePosts.ts +16 -14
- package/src/data/queries/useSiteApp.ts +130 -0
- package/src/data/queries/useSubscriptions.ts +28 -9
- package/src/i18n/de.json +891 -2
- package/src/i18n/en.json +891 -2
- package/src/index.ts +36 -7
- package/src/runtime/pages.spec.ts +36 -0
- package/src/runtime/pages.ts +52 -27
- package/src/server/_tests/buildWebManifest.spec.ts +1 -1
- package/src/server/_tests/forgeServiceWorkerScript.spec.ts +4 -4
- package/src/types/models.ts +11 -0
- package/src/ui/analytics/ForgeAnalytics.tsx +64 -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/metaCommerceEvents.spec.tsx +159 -0
- package/src/ui/analytics/useMetaEvents.ts +78 -0
- package/src/ui/format/_tests/membershipGate.spec.ts +6 -6
- package/src/ui/format/membershipGate.ts +19 -15
- 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/communityGate.spec.tsx +80 -0
- package/src/ui/headless/_tests/communityHarness.tsx +212 -0
- 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/communityProvider.spec.tsx +151 -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/useCommunityInvite.spec.tsx +104 -0
- package/src/ui/headless/_tests/useCommunityJoin.spec.tsx +596 -0
- package/src/ui/headless/_tests/useCommunityMembership.spec.tsx +179 -0
- package/src/ui/headless/_tests/useCommunityModeration.spec.tsx +420 -0
- package/src/ui/headless/_tests/useCommunityReads.spec.tsx +295 -0
- package/src/ui/headless/_tests/useCommunityUpgrade.spec.tsx +157 -0
- 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 +30 -3
- package/src/ui/headless/chat/useChatAttachmentUpload.ts +9 -6
- package/src/ui/headless/chat/useChatRoom.ts +35 -1
- package/src/ui/headless/communities/CommunityGate.tsx +67 -0
- package/src/ui/headless/communities/index.ts +96 -0
- package/src/ui/headless/communities/paths.ts +36 -0
- package/src/ui/headless/communities/useCommunity.ts +140 -0
- package/src/ui/headless/communities/useCommunityAbout.ts +71 -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 +91 -0
- package/src/ui/headless/communities/useCommunityInvite.ts +116 -0
- package/src/ui/headless/communities/useCommunityJoin.ts +494 -0
- package/src/ui/headless/communities/useCommunityMembership.ts +182 -0
- package/src/ui/headless/communities/useCommunityUpgrade.ts +213 -0
- package/src/ui/headless/communities/useCommunityWiki.ts +266 -0
- package/src/ui/headless/communities/useMyMemberships.ts +59 -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/useCommunityAttachmentUpload.ts +7 -3
- 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 +21 -4
- package/src/ui/headless/memberHome/index.ts +11 -1
- package/src/ui/headless/memberHome/memberHomeBar.ts +91 -0
- package/src/ui/headless/memberHome/useMemberHomeNav.ts +84 -44
- package/src/ui/headless/membership/_tests/membershipCheckoutRefetch.spec.tsx +8 -0
- package/src/ui/headless/membership/_tests/useMembershipCheckoutApplication.spec.tsx +5 -4
- package/src/ui/headless/membership/_tests/useMembershipCheckoutQuestionnaire.spec.tsx +1 -1
- package/src/ui/headless/membership/_tests/useMembershipCheckoutTrial.spec.tsx +1 -1
- package/src/ui/headless/membership/useMembershipCheckout.ts +89 -22
- package/src/ui/headless/membership/useMembershipGateNotice.ts +25 -7
- 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/posts/upsell.ts +54 -8
- package/src/ui/headless/posts/useMemberPostsFeed.ts +6 -5
- package/src/ui/headless/posts/usePostCollectionDetail.ts +7 -4
- package/src/ui/headless/posts/usePostCollectionsList.ts +6 -5
- package/src/ui/headless/posts/usePostItem.ts +6 -5
- 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 +28 -12
- package/src/ui/styled/AccountDashboard.tsx +754 -602
- 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/MembershipCheckout.tsx +37 -17
- package/src/ui/styled/MembershipTierCallout.tsx +20 -1
- package/src/ui/styled/MembershipTiers.tsx +49 -11
- package/src/ui/styled/MusicLinkPage.tsx +5 -5
- package/src/ui/styled/Paywall.tsx +1 -1
- 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 +6 -6
- package/src/ui/styled/_tests/AccountDashboardMemberships.spec.tsx +151 -0
- package/src/ui/styled/_tests/AccountDashboardRentals.spec.tsx +1 -1
- package/src/ui/styled/_tests/AccountNotifications.spec.tsx +135 -0
- package/src/ui/styled/_tests/CourseCallsSection.spec.tsx +7 -1
- package/src/ui/styled/_tests/DownloadPage.spec.tsx +68 -0
- package/src/ui/styled/_tests/MembershipCheckout.spec.tsx +54 -11
- package/src/ui/styled/_tests/membershipTierCallout.spec.tsx +1 -1
- package/src/ui/styled/_tests/membershipTiersCuratedAccess.spec.tsx +1 -1
- 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/CommunitiesList.tsx +88 -0
- package/src/ui/styled/communities/CommunityAbout.tsx +145 -0
- 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 +298 -0
- package/src/ui/styled/communities/CommunityJoinSheet.tsx +544 -0
- package/src/ui/styled/communities/CommunityMembershipMenu.tsx +249 -0
- package/src/ui/styled/communities/CommunityPending.tsx +49 -0
- package/src/ui/styled/communities/CommunityPublicWiki.tsx +75 -0
- package/src/ui/styled/communities/CommunityUpgradeSheet.tsx +137 -0
- package/src/ui/styled/communities/CommunityWiki.tsx +289 -0
- package/src/ui/styled/communities/MyMembershipsList.tsx +115 -0
- 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/CommunityPages.spec.tsx +102 -0
- package/src/ui/styled/communities/_tests/CommunityWiki.spec.tsx +223 -0
- package/src/ui/styled/communities/index.ts +19 -0
- package/src/ui/styled/communities/shared.tsx +177 -0
- 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/CommunityComposer.spec.tsx +4 -1
- 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/_tests/CommunityPostDetail.spec.tsx +4 -1
- package/src/ui/styled/community/index.ts +37 -2
- package/src/ui/styled/forge-utilities.css +253 -0
- package/src/ui/styled/members/CallRecordingActions.tsx +182 -0
- package/src/ui/styled/members/CallTranscriptViewer.tsx +424 -0
- package/src/ui/styled/members/MemberCallsCalendar.tsx +41 -54
- package/src/ui/styled/members/MemberHomeLayout.tsx +327 -29
- package/src/ui/styled/members/MemberPostCard.tsx +9 -5
- package/src/ui/styled/members/MemberPostsFeed.tsx +3 -2
- package/src/ui/styled/members/PostCollectionDetail.tsx +18 -10
- package/src/ui/styled/members/PostCollectionsGrid.tsx +24 -10
- package/src/ui/styled/members/_tests/MemberCallsCalendar.spec.tsx +167 -8
- package/src/ui/styled/members/callTranscriptText.ts +67 -0
- 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/metaEvents.spec.ts +264 -0
- package/src/utils/_tests/safeRedirect.spec.ts +2 -2
- package/src/utils/landing.ts +19 -6
- package/src/utils/metaEvents.ts +302 -0
- package/src/ui/headless/membership/MembershipGate.tsx +0 -44
- package/src/ui/styled/_tests/AccountDashboardCommunity.spec.tsx +0 -149
- package/src/ui/styled/_tests/AccountDashboardTrial.spec.tsx +0 -105
|
@@ -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
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import axios from "axios";
|
|
2
2
|
import { useCallback, useRef, useState } from "react";
|
|
3
3
|
import { useForge } from "../../../provider/ForgeProvider";
|
|
4
|
+
import { useOptionalCommunityContext } from "../../../contexts/CommunityContext";
|
|
4
5
|
import { attachmentKind, type AttachmentKind } from "../chat/attachmentKind";
|
|
5
6
|
import type { CommunityAttachmentInput } from "../../../data/queries/useCommunity";
|
|
6
7
|
|
|
@@ -31,11 +32,14 @@ const toApiType = (kind: AttachmentKind): CommunityAttachmentInput["type"] => {
|
|
|
31
32
|
/**
|
|
32
33
|
* Headless attachment composer for community posts/replies: stage local files
|
|
33
34
|
* (with previews), upload to R2 via the member-scoped presigned endpoint
|
|
34
|
-
* (POST /public/community/attachments/presigned-url
|
|
35
|
+
* (POST /public/community/attachments/presigned-url: same flow as chat's
|
|
35
36
|
* useChatAttachmentUpload), and hand back `CommunityAttachmentInput[]`.
|
|
36
37
|
*/
|
|
37
38
|
export function useCommunityAttachmentUpload() {
|
|
38
39
|
const { client, profileId } = useForge();
|
|
40
|
+
// The community the attachment is posted into, when there is one. Soft: the
|
|
41
|
+
// composer also serves course spaces, which belong to no community.
|
|
42
|
+
const communityId = useOptionalCommunityContext()?.communityId;
|
|
39
43
|
const [pending, setPending] = useState<PendingCommunityAttachment[]>([]);
|
|
40
44
|
const [error, setError] = useState<string | null>(null);
|
|
41
45
|
const pendingRef = useRef<PendingCommunityAttachment[]>([]);
|
|
@@ -96,7 +100,7 @@ export function useCommunityAttachmentUpload() {
|
|
|
96
100
|
try {
|
|
97
101
|
const { data } = await client.post<{ presignedUrl: string; remoteUrl: string }>(
|
|
98
102
|
"/public/community/attachments/presigned-url",
|
|
99
|
-
{ fileName: item.file.name, profileId },
|
|
103
|
+
{ fileName: item.file.name, profileId, ...(communityId ? { communityId } : {}) },
|
|
100
104
|
);
|
|
101
105
|
await axios.put(data.presignedUrl, item.file, {
|
|
102
106
|
headers: { "Content-Type": item.file.type || "application/octet-stream" },
|
|
@@ -121,7 +125,7 @@ export function useCommunityAttachmentUpload() {
|
|
|
121
125
|
}
|
|
122
126
|
|
|
123
127
|
return uploaded;
|
|
124
|
-
}, [client, profileId, patch]);
|
|
128
|
+
}, [client, profileId, communityId, patch]);
|
|
125
129
|
|
|
126
130
|
return {
|
|
127
131
|
pending,
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { useEffect, useMemo, useState } from "react";
|
|
2
|
+
import {
|
|
3
|
+
useCommunityDirectory,
|
|
4
|
+
useCommunityMemberMap,
|
|
5
|
+
type CommunityDirectoryMember,
|
|
6
|
+
type CommunityDirectoryRole,
|
|
7
|
+
type CommunityDirectorySort,
|
|
8
|
+
type CommunityMemberMapCountry,
|
|
9
|
+
} from "../../../data/queries/useCommunityMembers";
|
|
10
|
+
import { COUNTRY_CENTROIDS, countryName } from "./countries";
|
|
11
|
+
|
|
12
|
+
// ── Pure helpers (unit-tested; no React) ─────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
export const DIRECTORY_SORTS: readonly CommunityDirectorySort[] = ["active", "points", "newest", "name"];
|
|
15
|
+
export const DIRECTORY_LEVELS: readonly number[] = [1, 2, 3, 4, 5, 6, 7, 8, 9];
|
|
16
|
+
|
|
17
|
+
/** One dot on the member globe: a country, where it is, and how many members. */
|
|
18
|
+
export interface MemberGlobePoint {
|
|
19
|
+
countryCode: string;
|
|
20
|
+
latitude: number;
|
|
21
|
+
longitude: number;
|
|
22
|
+
members: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** A dot as the globe library draws it. */
|
|
26
|
+
export interface MemberGlobeMarker {
|
|
27
|
+
location: [number, number];
|
|
28
|
+
size: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The member map's countries, cleaned: codes upper-cased, repeats merged,
|
|
33
|
+
* empty and malformed rows dropped, biggest first. Every country is here,
|
|
34
|
+
* including one the globe has no centre for, so the list under the globe
|
|
35
|
+
* leaves nobody out.
|
|
36
|
+
*/
|
|
37
|
+
export function memberMapCountries(rows: CommunityMemberMapCountry[] | undefined): CommunityMemberMapCountry[] {
|
|
38
|
+
const merged = new Map<string, number>();
|
|
39
|
+
for (const row of Array.isArray(rows) ? rows : []) {
|
|
40
|
+
const code = typeof row.countryCode === "string" ? row.countryCode.trim().toUpperCase() : "";
|
|
41
|
+
const members = Number(row.members) || 0;
|
|
42
|
+
if (!/^[A-Z]{2}$/.test(code) || members <= 0) continue;
|
|
43
|
+
merged.set(code, (merged.get(code) ?? 0) + members);
|
|
44
|
+
}
|
|
45
|
+
return [...merged.entries()]
|
|
46
|
+
.map(([countryCode, members]) => ({ countryCode, members }))
|
|
47
|
+
.sort((a, b) => b.members - a.members || a.countryCode.localeCompare(b.countryCode));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The member map's countries as globe dots, placed at each country's centre.
|
|
52
|
+
* A country with no known centre has no dot (it still lists, through
|
|
53
|
+
* `memberMapCountries`). Biggest first.
|
|
54
|
+
*/
|
|
55
|
+
export function memberGlobePoints(rows: CommunityMemberMapCountry[] | undefined): MemberGlobePoint[] {
|
|
56
|
+
const points: MemberGlobePoint[] = [];
|
|
57
|
+
for (const { countryCode, members } of memberMapCountries(rows)) {
|
|
58
|
+
const centre = COUNTRY_CENTROIDS[countryCode];
|
|
59
|
+
if (centre) points.push({ countryCode, latitude: centre[0], longitude: centre[1], members });
|
|
60
|
+
}
|
|
61
|
+
return points;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Dot sizes by the square root of each country's share of the biggest, so one
|
|
66
|
+
* large country does not hide the rest: 0.03 for the smallest to 0.12 for the
|
|
67
|
+
* biggest.
|
|
68
|
+
*/
|
|
69
|
+
export function memberGlobeMarkers(points: MemberGlobePoint[]): MemberGlobeMarker[] {
|
|
70
|
+
const max = Math.max(1, ...points.map((p) => p.members));
|
|
71
|
+
return points.map((p) => ({ location: [p.latitude, p.longitude], size: 0.03 + 0.09 * Math.sqrt(p.members / max) }));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** cobe's rotation that brings a place to the front. */
|
|
75
|
+
export function globeAnglesFor(latitude: number, longitude: number): [number, number] {
|
|
76
|
+
return [Math.PI - ((longitude * Math.PI) / 180 - Math.PI / 2), (latitude * Math.PI) / 180];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** "Berlin, Germany", "Germany", "Berlin", or null: where a member says they are. */
|
|
80
|
+
export function memberLocationLabel(
|
|
81
|
+
member: Pick<CommunityDirectoryMember, "city" | "countryCode">,
|
|
82
|
+
locale = "en",
|
|
83
|
+
): string | null {
|
|
84
|
+
const city = member.city?.trim() || null;
|
|
85
|
+
const country = countryName(member.countryCode, locale);
|
|
86
|
+
if (city && country) return `${city}, ${country}`;
|
|
87
|
+
return city ?? country;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ── Hooks ────────────────────────────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
/** How long the search box settles before the directory is asked. */
|
|
93
|
+
export const DIRECTORY_SEARCH_DEBOUNCE_MS = 300;
|
|
94
|
+
|
|
95
|
+
export type CommunityMembersView = "list" | "map";
|
|
96
|
+
|
|
97
|
+
export interface UseCommunityMemberDirectoryOptions {
|
|
98
|
+
/** Cards per page. Default 24. */
|
|
99
|
+
pageSize?: number;
|
|
100
|
+
initialView?: CommunityMembersView;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Headless member directory for the surrounding community: the search (sent
|
|
105
|
+
* once typing settles), role and level filters, sort, paging, and the list or
|
|
106
|
+
* map view. Changing any filter goes back to page 1. The map's data loads only
|
|
107
|
+
* once the map is shown.
|
|
108
|
+
*/
|
|
109
|
+
export function useCommunityMemberDirectory(opts: UseCommunityMemberDirectoryOptions = {}) {
|
|
110
|
+
const [search, setSearch] = useState("");
|
|
111
|
+
const [q, setQ] = useState("");
|
|
112
|
+
const [role, setRoleState] = useState<CommunityDirectoryRole | null>(null);
|
|
113
|
+
const [minLevel, setMinLevelState] = useState<number | null>(null);
|
|
114
|
+
const [sort, setSortState] = useState<CommunityDirectorySort>("active");
|
|
115
|
+
const [page, setPage] = useState(1);
|
|
116
|
+
const [view, setView] = useState<CommunityMembersView>(opts.initialView ?? "list");
|
|
117
|
+
|
|
118
|
+
useEffect(() => {
|
|
119
|
+
const id = setTimeout(() => {
|
|
120
|
+
setQ(search.trim());
|
|
121
|
+
setPage(1);
|
|
122
|
+
}, DIRECTORY_SEARCH_DEBOUNCE_MS);
|
|
123
|
+
return () => clearTimeout(id);
|
|
124
|
+
}, [search]);
|
|
125
|
+
|
|
126
|
+
const directory = useCommunityDirectory({ q, role, minLevel, sort, page, limit: opts.pageSize });
|
|
127
|
+
const map = useCommunityMemberMap({ enabled: view === "map" });
|
|
128
|
+
const countries = useMemo(() => memberMapCountries(map.data), [map.data]);
|
|
129
|
+
const points = useMemo(() => memberGlobePoints(map.data), [map.data]);
|
|
130
|
+
|
|
131
|
+
const resetting =
|
|
132
|
+
<T>(set: (value: T) => void) =>
|
|
133
|
+
(value: T) => {
|
|
134
|
+
set(value);
|
|
135
|
+
setPage(1);
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
// Filters
|
|
140
|
+
search,
|
|
141
|
+
setSearch,
|
|
142
|
+
role,
|
|
143
|
+
setRole: resetting(setRoleState),
|
|
144
|
+
minLevel,
|
|
145
|
+
setMinLevel: resetting(setMinLevelState),
|
|
146
|
+
sort,
|
|
147
|
+
setSort: resetting(setSortState),
|
|
148
|
+
/** Any filter other than the default order is on. */
|
|
149
|
+
filtered: !!q || !!role || !!minLevel,
|
|
150
|
+
// Results
|
|
151
|
+
members: directory.data?.data ?? [],
|
|
152
|
+
total: directory.data?.total ?? 0,
|
|
153
|
+
page,
|
|
154
|
+
hasNextPage: !!directory.data?.hasNextPage,
|
|
155
|
+
hasPreviousPage: page > 1,
|
|
156
|
+
nextPage: () => setPage((p) => p + 1),
|
|
157
|
+
previousPage: () => setPage((p) => Math.max(1, p - 1)),
|
|
158
|
+
isLoading: directory.isLoading,
|
|
159
|
+
/** True while a new page or filter is loading behind the current results. */
|
|
160
|
+
isRefreshing: directory.isFetching && !directory.isLoading,
|
|
161
|
+
isError: directory.isError,
|
|
162
|
+
/** 403: the viewer is not a member of this community. */
|
|
163
|
+
forbidden: (directory.error as { response?: { status?: number } } | null)?.response?.status === 403,
|
|
164
|
+
// Map
|
|
165
|
+
view,
|
|
166
|
+
setView,
|
|
167
|
+
/** Globe dots: countries with a known centre. */
|
|
168
|
+
mapPoints: points,
|
|
169
|
+
/** Every country with members on the map, for the list. */
|
|
170
|
+
mapCountries: countries,
|
|
171
|
+
mapMembers: countries.reduce((n, c) => n + c.members, 0),
|
|
172
|
+
mapLoading: view === "map" && map.isLoading,
|
|
173
|
+
mapError: map.isError,
|
|
174
|
+
};
|
|
175
|
+
}
|