@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
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import { useContext, useMemo, useState } from "react";
|
|
2
|
+
import { PublicAuthContext } from "../../../contexts/PublicAuthContext";
|
|
3
|
+
import {
|
|
4
|
+
useCommunityCanModerate,
|
|
5
|
+
useMyCommunityProfile,
|
|
6
|
+
type CommunityNotificationItem,
|
|
7
|
+
type CommunityPost,
|
|
8
|
+
type CommunityReply,
|
|
9
|
+
} from "../../../data/queries/useCommunity";
|
|
10
|
+
import {
|
|
11
|
+
useCommunityModerationLog,
|
|
12
|
+
useCommunityModerationQueue,
|
|
13
|
+
useModerateCommunityPost,
|
|
14
|
+
useModerateCommunityReply,
|
|
15
|
+
useMuteCommunityMember,
|
|
16
|
+
useResolveCommunityReport,
|
|
17
|
+
type CommunityModerationLogEntry,
|
|
18
|
+
type CommunityPendingPost,
|
|
19
|
+
type CommunityPendingReply,
|
|
20
|
+
type CommunityPostModerationAction,
|
|
21
|
+
} from "../../../data/queries/useCommunityModeration";
|
|
22
|
+
|
|
23
|
+
// ── Pure helpers (unit-tested; no React) ─────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
/** Why something was held, in a shape a renderer can put into words. */
|
|
26
|
+
export type CommunityHoldReason =
|
|
27
|
+
| { kind: "new_member" }
|
|
28
|
+
| { kind: "all_posts" }
|
|
29
|
+
| { kind: "keyword"; word: string }
|
|
30
|
+
| { kind: "other"; raw: string }
|
|
31
|
+
| null;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Read a held post's `moderationReason`: `approval_new_member`,
|
|
35
|
+
* `approval_all` or `keyword:<word>`. Anything else is passed through as
|
|
36
|
+
* `other`, so a reason a newer server adds still shows rather than vanishing.
|
|
37
|
+
*/
|
|
38
|
+
export function describeHoldReason(reason: string | null | undefined): CommunityHoldReason {
|
|
39
|
+
if (!reason) return null;
|
|
40
|
+
if (reason === "approval_new_member") return { kind: "new_member" };
|
|
41
|
+
if (reason === "approval_all") return { kind: "all_posts" };
|
|
42
|
+
if (reason.startsWith("keyword:")) return { kind: "keyword", word: reason.slice("keyword:".length) };
|
|
43
|
+
return { kind: "other", raw: reason };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The mute lengths a moderator can pick on the site, in days. */
|
|
47
|
+
export const COMMUNITY_MUTE_DAYS = [1, 7, 30] as const;
|
|
48
|
+
export type CommunityMuteDays = (typeof COMMUNITY_MUTE_DAYS)[number];
|
|
49
|
+
|
|
50
|
+
/** The ISO time a mute of `days` started at `now` ends. */
|
|
51
|
+
export function communityMuteUntil(days: number, now: Date = new Date()): string {
|
|
52
|
+
return new Date(now.getTime() + days * 24 * 60 * 60 * 1000).toISOString();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The server's message on a failed call, when it sent one. */
|
|
56
|
+
export function moderationErrorMessage(err: unknown): string | null {
|
|
57
|
+
const message = (err as { response?: { data?: { message?: unknown } } })?.response?.data?.message;
|
|
58
|
+
return typeof message === "string" && message.trim() ? message : null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** A moderation outcome notice about the member's own post or reply. */
|
|
62
|
+
export interface CommunityModerationOutcome {
|
|
63
|
+
outcome: "approved" | "rejected";
|
|
64
|
+
subject: "post" | "reply";
|
|
65
|
+
reason: string | null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The moderation outcome a `mod` notification carries, or null for any other
|
|
70
|
+
* notification. A `mod` notice about the community itself (a mute) carries no
|
|
71
|
+
* outcome and stays null, so it keeps rendering as it always has.
|
|
72
|
+
*/
|
|
73
|
+
export function describeModerationNotification(
|
|
74
|
+
n: { type: string; entityType: string | null; data?: unknown } | null | undefined,
|
|
75
|
+
): CommunityModerationOutcome | null {
|
|
76
|
+
if (!n || n.type !== "mod") return null;
|
|
77
|
+
const subject = n.entityType === "community_post" ? "post" : n.entityType === "community_reply" ? "reply" : null;
|
|
78
|
+
const data = (n.data && typeof n.data === "object" ? n.data : {}) as NonNullable<CommunityNotificationItem["data"]>;
|
|
79
|
+
const outcome = data.outcome;
|
|
80
|
+
if (!subject || (outcome !== "approved" && outcome !== "rejected")) return null;
|
|
81
|
+
const reason = typeof data.reason === "string" && data.reason.trim() ? data.reason.trim() : null;
|
|
82
|
+
return { outcome, subject, reason };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** What a log entry can be undone to, when it can. */
|
|
86
|
+
export interface CommunityLogRestoreTarget {
|
|
87
|
+
type: "post" | "reply";
|
|
88
|
+
id: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Which log entries still offer "Restore": a removal (or a report resolved by
|
|
93
|
+
* hiding the content) of a post or reply that no NEWER entry on the page has
|
|
94
|
+
* already restored. The log is newest first. An entry whose restore is on an
|
|
95
|
+
* older page may still show the button; restoring twice is harmless.
|
|
96
|
+
*/
|
|
97
|
+
export function restorableLogEntries(entries: CommunityModerationLogEntry[]): Map<string, CommunityLogRestoreTarget> {
|
|
98
|
+
const out = new Map<string, CommunityLogRestoreTarget>();
|
|
99
|
+
const restoredAfter = new Set<string>();
|
|
100
|
+
for (const entry of entries) {
|
|
101
|
+
if (!entry.entityId) continue;
|
|
102
|
+
const type =
|
|
103
|
+
entry.entityType === "community_post" ? "post" : entry.entityType === "community_reply" ? "reply" : null;
|
|
104
|
+
if (!type) continue;
|
|
105
|
+
const key = `${type}:${entry.entityId}`;
|
|
106
|
+
if (entry.action === "restore") {
|
|
107
|
+
restoredAfter.add(key);
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if ((entry.action === "remove" || entry.action === "report_resolved_hidden") && !restoredAfter.has(key)) {
|
|
111
|
+
out.set(entry.id, { type, id: entry.entityId });
|
|
112
|
+
// An older removal of the same thing is already covered by this one.
|
|
113
|
+
restoredAfter.add(key);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ── Hooks ────────────────────────────────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Whether the viewer is signed in. Read without `usePublicAuth()` so the
|
|
123
|
+
* moderation tools render (as nothing) outside an auth provider rather than
|
|
124
|
+
* throwing: a post card is useful in places no one is signed in.
|
|
125
|
+
*/
|
|
126
|
+
function useViewerId(): string | null {
|
|
127
|
+
return useContext(PublicAuthContext)?.user?.id ?? null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function useIsSignedIn() {
|
|
131
|
+
return !!useViewerId();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
type Outcome = Promise<boolean>;
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Headless moderator actions for the inline menu on a post or reply: pin,
|
|
138
|
+
* lock, remove (with an optional reason) and restore, plus muting the author
|
|
139
|
+
* for a while. `canModerate` is false for everyone but a moderator of THIS
|
|
140
|
+
* community (or an owner/admin), and every action resolves `false` with
|
|
141
|
+
* `error` set when the server refuses. Bans are not here: only an admin bans.
|
|
142
|
+
*/
|
|
143
|
+
export function useCommunityModeratorActions() {
|
|
144
|
+
const viewerId = useViewerId();
|
|
145
|
+
const canModerate = useCommunityCanModerate(!!viewerId);
|
|
146
|
+
const postMutation = useModerateCommunityPost();
|
|
147
|
+
const replyMutation = useModerateCommunityReply();
|
|
148
|
+
const muteMutation = useMuteCommunityMember();
|
|
149
|
+
const [error, setError] = useState<string | null>(null);
|
|
150
|
+
const [failed, setFailed] = useState(false);
|
|
151
|
+
|
|
152
|
+
const run = async (call: () => Promise<unknown>): Outcome => {
|
|
153
|
+
setError(null);
|
|
154
|
+
setFailed(false);
|
|
155
|
+
try {
|
|
156
|
+
await call();
|
|
157
|
+
return true;
|
|
158
|
+
} catch (err) {
|
|
159
|
+
setError(moderationErrorMessage(err));
|
|
160
|
+
setFailed(true);
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const post = (action: CommunityPostModerationAction) => (target: Pick<CommunityPost, "id">, reason?: string) =>
|
|
166
|
+
run(() => postMutation.mutateAsync({ postId: target.id, action, reason }));
|
|
167
|
+
|
|
168
|
+
return {
|
|
169
|
+
canModerate,
|
|
170
|
+
/** The signed-in member, so a renderer can leave "mute" off their own content. */
|
|
171
|
+
currentAccountId: viewerId,
|
|
172
|
+
isPending: postMutation.isPending || replyMutation.isPending || muteMutation.isPending,
|
|
173
|
+
/** The server's message for the last failed action, when it sent one. */
|
|
174
|
+
error,
|
|
175
|
+
/** The last action failed (with or without a message from the server). */
|
|
176
|
+
failed,
|
|
177
|
+
clearError: () => {
|
|
178
|
+
setError(null);
|
|
179
|
+
setFailed(false);
|
|
180
|
+
},
|
|
181
|
+
pinPost: post("pin"),
|
|
182
|
+
unpinPost: post("unpin"),
|
|
183
|
+
lockPost: post("lock"),
|
|
184
|
+
unlockPost: post("unlock"),
|
|
185
|
+
removePost: post("remove"),
|
|
186
|
+
restorePost: post("restore"),
|
|
187
|
+
removeReply: (reply: Pick<CommunityReply, "id" | "postId">, reason?: string) =>
|
|
188
|
+
run(() => replyMutation.mutateAsync({ replyId: reply.id, postId: reply.postId, action: "remove", reason })),
|
|
189
|
+
restoreReply: (reply: Pick<CommunityReply, "id" | "postId">) =>
|
|
190
|
+
run(() => replyMutation.mutateAsync({ replyId: reply.id, postId: reply.postId, action: "restore" })),
|
|
191
|
+
/** Mute a member in this community for `days` from now. */
|
|
192
|
+
muteMember: (accountId: string, days: CommunityMuteDays) =>
|
|
193
|
+
run(() => muteMutation.mutateAsync({ accountId, mutedUntil: communityMuteUntil(days) })),
|
|
194
|
+
unmuteMember: (accountId: string) => run(() => muteMutation.mutateAsync({ accountId, mutedUntil: null })),
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export type CommunityReportResolution = "resolve" | "resolve_and_hide" | "dismiss";
|
|
199
|
+
|
|
200
|
+
export interface UseCommunityModerationOptions {
|
|
201
|
+
/** Log page size. Default 20. */
|
|
202
|
+
logPageSize?: number;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Headless Moderation tab: the open reports, the posts and replies held for
|
|
207
|
+
* approval, and the moderation log (paged), with every action on them. Loads
|
|
208
|
+
* nothing until the viewer is known to moderate this community. `busyId` is the
|
|
209
|
+
* row an action is running on, so a renderer can disable just that row.
|
|
210
|
+
*/
|
|
211
|
+
export function useCommunityModeration(opts: UseCommunityModerationOptions = {}) {
|
|
212
|
+
const signedIn = useIsSignedIn();
|
|
213
|
+
const me = useMyCommunityProfile(signedIn);
|
|
214
|
+
const canModerate = signedIn && !!me.data?.canModerate;
|
|
215
|
+
const [logPage, setLogPage] = useState(1);
|
|
216
|
+
const queue = useCommunityModerationQueue(canModerate);
|
|
217
|
+
const log = useCommunityModerationLog(logPage, opts.logPageSize ?? 20, canModerate);
|
|
218
|
+
|
|
219
|
+
const resolve = useResolveCommunityReport();
|
|
220
|
+
const postMutation = useModerateCommunityPost();
|
|
221
|
+
const replyMutation = useModerateCommunityReply();
|
|
222
|
+
|
|
223
|
+
const [busyId, setBusyId] = useState<string | null>(null);
|
|
224
|
+
const [error, setError] = useState<{ id: string; message: string | null } | null>(null);
|
|
225
|
+
|
|
226
|
+
const run = async (id: string, call: () => Promise<unknown>): Outcome => {
|
|
227
|
+
setBusyId(id);
|
|
228
|
+
setError(null);
|
|
229
|
+
try {
|
|
230
|
+
await call();
|
|
231
|
+
return true;
|
|
232
|
+
} catch (err) {
|
|
233
|
+
setError({ id, message: moderationErrorMessage(err) });
|
|
234
|
+
return false;
|
|
235
|
+
} finally {
|
|
236
|
+
setBusyId(null);
|
|
237
|
+
}
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
const logEntries = useMemo(() => log.data?.data ?? [], [log.data]);
|
|
241
|
+
const restorable = useMemo(() => restorableLogEntries(logEntries), [logEntries]);
|
|
242
|
+
|
|
243
|
+
return {
|
|
244
|
+
canModerate,
|
|
245
|
+
/** Still finding out whether the viewer moderates this community. */
|
|
246
|
+
isChecking: signedIn && me.isLoading,
|
|
247
|
+
/** The queue's first load. */
|
|
248
|
+
isLoading: canModerate && queue.isLoading,
|
|
249
|
+
reports: queue.data?.reports.data ?? [],
|
|
250
|
+
reportsTotal: queue.data?.reports.total ?? 0,
|
|
251
|
+
pendingPosts: queue.data?.pending.posts ?? [],
|
|
252
|
+
pendingReplies: queue.data?.pending.replies ?? [],
|
|
253
|
+
queueError: queue.isError,
|
|
254
|
+
busyId,
|
|
255
|
+
/** The last failed action: which row, and the server's message when it sent one. */
|
|
256
|
+
error,
|
|
257
|
+
clearError: () => setError(null),
|
|
258
|
+
|
|
259
|
+
resolveReport: (reportId: string, resolution: CommunityReportResolution) =>
|
|
260
|
+
run(reportId, () =>
|
|
261
|
+
resolve.mutateAsync({
|
|
262
|
+
reportId,
|
|
263
|
+
action: resolution === "dismiss" ? "dismissed" : "resolved",
|
|
264
|
+
hideContent: resolution === "resolve_and_hide",
|
|
265
|
+
}),
|
|
266
|
+
),
|
|
267
|
+
approvePost: (item: Pick<CommunityPendingPost, "id">) =>
|
|
268
|
+
run(item.id, () => postMutation.mutateAsync({ postId: item.id, action: "approve" })),
|
|
269
|
+
rejectPost: (item: Pick<CommunityPendingPost, "id">, reason?: string) =>
|
|
270
|
+
run(item.id, () => postMutation.mutateAsync({ postId: item.id, action: "reject", reason })),
|
|
271
|
+
approveReply: (item: Pick<CommunityPendingReply, "id" | "postId">) =>
|
|
272
|
+
run(item.id, () => replyMutation.mutateAsync({ replyId: item.id, postId: item.postId, action: "approve" })),
|
|
273
|
+
rejectReply: (item: Pick<CommunityPendingReply, "id" | "postId">, reason?: string) =>
|
|
274
|
+
run(item.id, () =>
|
|
275
|
+
replyMutation.mutateAsync({ replyId: item.id, postId: item.postId, action: "reject", reason }),
|
|
276
|
+
),
|
|
277
|
+
|
|
278
|
+
log: {
|
|
279
|
+
entries: logEntries,
|
|
280
|
+
page: logPage,
|
|
281
|
+
total: log.data?.total ?? 0,
|
|
282
|
+
hasNextPage: !!log.data?.hasNextPage,
|
|
283
|
+
hasPreviousPage: logPage > 1,
|
|
284
|
+
isLoading: canModerate && log.isLoading,
|
|
285
|
+
nextPage: () => setLogPage((p) => p + 1),
|
|
286
|
+
previousPage: () => setLogPage((p) => Math.max(1, p - 1)),
|
|
287
|
+
/** Entries that can still be undone, by entry id. */
|
|
288
|
+
restorable,
|
|
289
|
+
restore: (entry: CommunityModerationLogEntry) => {
|
|
290
|
+
const target = restorable.get(entry.id);
|
|
291
|
+
if (!target) return Promise.resolve(false);
|
|
292
|
+
return run(entry.id, () =>
|
|
293
|
+
target.type === "post"
|
|
294
|
+
? postMutation.mutateAsync({ postId: target.id, action: "restore" })
|
|
295
|
+
: replyMutation.mutateAsync({ replyId: target.id, action: "restore" }),
|
|
296
|
+
);
|
|
297
|
+
},
|
|
298
|
+
},
|
|
299
|
+
};
|
|
300
|
+
}
|
|
@@ -2,6 +2,7 @@ import { useEffect, useMemo } from "react";
|
|
|
2
2
|
import { useQueryClient } from "@tanstack/react-query";
|
|
3
3
|
import { usePublicAuth } from "../../../contexts/PublicAuthContext";
|
|
4
4
|
import {
|
|
5
|
+
applyCommunityContentHeld,
|
|
5
6
|
applyReactionDelta,
|
|
6
7
|
bumpFeedReplyCount,
|
|
7
8
|
prependPostToFeed,
|
|
@@ -12,10 +13,12 @@ import {
|
|
|
12
13
|
useJoinCommunitySpace,
|
|
13
14
|
useLeaveCommunitySpace,
|
|
14
15
|
useToggleCommunityReaction,
|
|
16
|
+
type CommunityContentHeldEvent,
|
|
15
17
|
type CommunityPost,
|
|
16
18
|
type CommunityReactionEvent,
|
|
17
19
|
type CommunityReply,
|
|
18
20
|
} from "../../../data/queries/useCommunity";
|
|
21
|
+
import { useCommunityAgentThinking } from "./useCommunityAgent";
|
|
19
22
|
|
|
20
23
|
/**
|
|
21
24
|
* Headless space feed: the space (from the gated spaces list), its paginated
|
|
@@ -25,7 +28,7 @@ import {
|
|
|
25
28
|
*
|
|
26
29
|
* The single primitive a creator needs to rebuild a space feed page.
|
|
27
30
|
*
|
|
28
|
-
* `idOrSlug` accepts either a space uuid or its per-profile slug
|
|
31
|
+
* `idOrSlug` accepts either a space uuid or its per-profile slug: the space is
|
|
29
32
|
* resolved from the gated spaces list by id OR slug, so slug-addressed deep
|
|
30
33
|
* links load the right space without a separate lookup.
|
|
31
34
|
*/
|
|
@@ -39,9 +42,12 @@ export function useCommunitySpaceFeed(idOrSlug?: string) {
|
|
|
39
42
|
[spaces, idOrSlug],
|
|
40
43
|
);
|
|
41
44
|
// Downstream hooks (posts, realtime room, create) are keyed by the canonical
|
|
42
|
-
// space uuid
|
|
45
|
+
// space uuid, so only wire them up once the space resolves from the list, so a
|
|
43
46
|
// slug-addressed link never leaks a slug into a uuid-keyed request/room.
|
|
44
|
-
|
|
47
|
+
// A space above the member's level is listed but closed: nothing is asked
|
|
48
|
+
// for it (the server would refuse), and no realtime room is joined.
|
|
49
|
+
const levelLocked = !!space?.levelLocked;
|
|
50
|
+
const spaceId = levelLocked ? undefined : space?.id;
|
|
45
51
|
|
|
46
52
|
const {
|
|
47
53
|
data: postPages,
|
|
@@ -80,6 +86,10 @@ export function useCommunitySpaceFeed(idOrSlug?: string) {
|
|
|
80
86
|
if (data.event === "communityReaction" && data.data?.entityId && data.data?.accountId !== user?.id) {
|
|
81
87
|
applyReactionDelta(queryClient, data.data as CommunityReactionEvent);
|
|
82
88
|
}
|
|
89
|
+
// Auto-moderation sent it back to the approval queue: out of the open feed.
|
|
90
|
+
if (data.event === "communityContentHeld" && data.data?.spaceId === spaceId) {
|
|
91
|
+
applyCommunityContentHeld(queryClient, data.data as unknown as CommunityContentHeldEvent, user?.id);
|
|
92
|
+
}
|
|
83
93
|
};
|
|
84
94
|
socket.on("message", onMessage);
|
|
85
95
|
return () => {
|
|
@@ -87,6 +97,9 @@ export function useCommunitySpaceFeed(idOrSlug?: string) {
|
|
|
87
97
|
};
|
|
88
98
|
}, [socket, spaceId, queryClient, user?.id]);
|
|
89
99
|
|
|
100
|
+
// The community AI answering a post in this space (cleared by the answer).
|
|
101
|
+
const agentThinking = useCommunityAgentThinking(socket);
|
|
102
|
+
|
|
90
103
|
// Join/leave the space room (mirrors chatJoinChannel).
|
|
91
104
|
useEffect(() => {
|
|
92
105
|
if (!spaceId || !socket) return;
|
|
@@ -108,7 +121,12 @@ export function useCommunitySpaceFeed(idOrSlug?: string) {
|
|
|
108
121
|
fetchNextPage,
|
|
109
122
|
hasMore: !!hasNextPage && !isFetchingNextPage,
|
|
110
123
|
currentAccountId: user?.id,
|
|
111
|
-
|
|
124
|
+
/** Whether the community AI is writing an answer under this post right now. */
|
|
125
|
+
isAgentThinking: agentThinking.isThinking,
|
|
126
|
+
/** The space is above the member's level: show it locked, not its feed. */
|
|
127
|
+
levelLocked,
|
|
128
|
+
minLevel: space?.minLevel ?? 1,
|
|
129
|
+
// Belonging: tiers govern access, join governs new-post notifications.
|
|
112
130
|
isJoined: space?.isJoined ?? false,
|
|
113
131
|
memberCount: space?.memberCount ?? null,
|
|
114
132
|
joinPending: joinSpace.isPending || leaveSpace.isPending,
|
|
@@ -2,6 +2,7 @@ import { useEffect, useMemo } from "react";
|
|
|
2
2
|
import { useQueryClient } from "@tanstack/react-query";
|
|
3
3
|
import { usePublicAuth } from "../../../contexts/PublicAuthContext";
|
|
4
4
|
import {
|
|
5
|
+
applyCommunityContentHeld,
|
|
5
6
|
appendReplyToThread,
|
|
6
7
|
applyReactionDelta,
|
|
7
8
|
useCommunityPost,
|
|
@@ -9,15 +10,17 @@ import {
|
|
|
9
10
|
useCommunitySocket,
|
|
10
11
|
useCreateCommunityReply,
|
|
11
12
|
useToggleCommunityReaction,
|
|
13
|
+
type CommunityContentHeldEvent,
|
|
12
14
|
type CommunityReactionEvent,
|
|
13
15
|
type CommunityReply,
|
|
14
16
|
type ReactableEntityType,
|
|
15
17
|
} from "../../../data/queries/useCommunity";
|
|
16
18
|
import { buildReplyTree } from "./replyTree";
|
|
19
|
+
import { useCommunityAgentThinking } from "./useCommunityAgent";
|
|
17
20
|
|
|
18
21
|
/**
|
|
19
22
|
* Headless post thread: the post, its full reply tree (assembled from the flat
|
|
20
|
-
* pages
|
|
23
|
+
* pages: data nesting is unlimited, display capping is the renderer's job),
|
|
21
24
|
* realtime reply/reaction updates via the post's space room, plus reply and
|
|
22
25
|
* toggle-reaction actions.
|
|
23
26
|
*/
|
|
@@ -52,13 +55,18 @@ export function useCommunityThread(postId?: string) {
|
|
|
52
55
|
data?: { spaceId?: string; postId?: string; reply?: CommunityReply } & Partial<CommunityReactionEvent>;
|
|
53
56
|
}) => {
|
|
54
57
|
if (data.event === "communityNewReply" && data.data?.postId === postId && data.data?.reply) {
|
|
55
|
-
// Skip own replies
|
|
56
|
-
|
|
58
|
+
// Skip own replies: the mutation already appended them. An answer from
|
|
59
|
+
// the community AI has no account, so it is never skipped.
|
|
60
|
+
if (user?.id && data.data.reply.authorAccountId === user.id) return;
|
|
57
61
|
appendReplyToThread(queryClient, postId, data.data.reply);
|
|
58
62
|
}
|
|
59
63
|
if (data.event === "communityReaction" && data.data?.entityId && data.data?.accountId !== user?.id) {
|
|
60
64
|
applyReactionDelta(queryClient, data.data as CommunityReactionEvent, { postId });
|
|
61
65
|
}
|
|
66
|
+
// Auto-moderation held this post, or a reply in it: out of the open thread.
|
|
67
|
+
if (data.event === "communityContentHeld" && data.data?.postId === postId) {
|
|
68
|
+
applyCommunityContentHeld(queryClient, data.data as unknown as CommunityContentHeldEvent, user?.id);
|
|
69
|
+
}
|
|
62
70
|
};
|
|
63
71
|
socket.on("message", onMessage);
|
|
64
72
|
return () => {
|
|
@@ -66,6 +74,9 @@ export function useCommunityThread(postId?: string) {
|
|
|
66
74
|
};
|
|
67
75
|
}, [socket, postId, queryClient, user?.id]);
|
|
68
76
|
|
|
77
|
+
// The community AI writing an answer in this thread (cleared by the answer).
|
|
78
|
+
const agentThinking = useCommunityAgentThinking(socket, (id) => id === postId);
|
|
79
|
+
|
|
69
80
|
useEffect(() => {
|
|
70
81
|
if (!spaceId || !socket) return;
|
|
71
82
|
const join = () => socket.emit("communityJoinSpace", { spaceId });
|
|
@@ -87,6 +98,8 @@ export function useCommunityThread(postId?: string) {
|
|
|
87
98
|
fetchNextPage,
|
|
88
99
|
hasMore: !!hasNextPage && !isFetchingNextPage,
|
|
89
100
|
currentAccountId: user?.id,
|
|
101
|
+
/** Whether the community AI is writing an answer in this thread right now. */
|
|
102
|
+
agentThinking: !!postId && agentThinking.isThinking(postId),
|
|
90
103
|
// Actions
|
|
91
104
|
createReply,
|
|
92
105
|
react: (entityType: Extract<ReactableEntityType, "community_post" | "community_reply">, entityId: string, emoji: string) =>
|
|
@@ -7,10 +7,16 @@ import {
|
|
|
7
7
|
import { buildLexicalState, mentionsInText, type MentionRef } from "./lexical";
|
|
8
8
|
|
|
9
9
|
export interface MentionSuggestion {
|
|
10
|
+
/** The member's account. Empty for the community AI, which has none. */
|
|
10
11
|
accountId: string;
|
|
11
12
|
name: string;
|
|
12
13
|
avatarUrl: string | null;
|
|
13
14
|
level: number;
|
|
15
|
+
/**
|
|
16
|
+
* Set when this suggestion is the community AI rather than a member. It is
|
|
17
|
+
* listed first and inserts an agent mention (`agentId`, never `accountId`).
|
|
18
|
+
*/
|
|
19
|
+
agentId?: string | null;
|
|
14
20
|
}
|
|
15
21
|
|
|
16
22
|
/**
|
|
@@ -24,7 +30,17 @@ export function useMentionComposer(initialText = "") {
|
|
|
24
30
|
const [text, setText] = useState(initialText);
|
|
25
31
|
const [caret, setCaret] = useState(initialText.length);
|
|
26
32
|
const [trackedMentions, setTrackedMentions] = useState<MentionRef[]>([]);
|
|
27
|
-
|
|
33
|
+
|
|
34
|
+
/** The active "@query" token immediately before the caret, if any. */
|
|
35
|
+
const activeToken = useMemo(() => {
|
|
36
|
+
const upToCaret = text.slice(0, caret);
|
|
37
|
+
const match = /(^|\s)@([^\s@]{0,40})$/.exec(upToCaret);
|
|
38
|
+
if (!match) return null;
|
|
39
|
+
return { query: match[2], start: caret - match[2].length - 1 };
|
|
40
|
+
}, [text, caret]);
|
|
41
|
+
|
|
42
|
+
// Searched on the server as the member types; nothing is asked for without an @token.
|
|
43
|
+
const { data: directory = [] } = useCommunityMentionDirectory(activeToken?.query);
|
|
28
44
|
|
|
29
45
|
const members: MentionSuggestion[] = useMemo(
|
|
30
46
|
() =>
|
|
@@ -37,14 +53,6 @@ export function useMentionComposer(initialText = "") {
|
|
|
37
53
|
[directory],
|
|
38
54
|
);
|
|
39
55
|
|
|
40
|
-
/** The active "@query" token immediately before the caret, if any. */
|
|
41
|
-
const activeToken = useMemo(() => {
|
|
42
|
-
const upToCaret = text.slice(0, caret);
|
|
43
|
-
const match = /(^|\s)@([^\s@]{0,40})$/.exec(upToCaret);
|
|
44
|
-
if (!match) return null;
|
|
45
|
-
return { query: match[2], start: caret - match[2].length - 1 };
|
|
46
|
-
}, [text, caret]);
|
|
47
|
-
|
|
48
56
|
const suggestions: MentionSuggestion[] = useMemo(() => {
|
|
49
57
|
if (!activeToken) return [];
|
|
50
58
|
const q = activeToken.query.toLowerCase();
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
useCommunityMentionDirectory,
|
|
5
5
|
type CommunityLeaderboardEntry,
|
|
6
6
|
} from "../../../data/queries/useCommunity";
|
|
7
|
+
import { useCommunityAgent } from "../../../data/queries/useCommunityAgent";
|
|
7
8
|
import type { MentionSuggestion } from "./useMentionComposer";
|
|
8
9
|
import { collectMentions, isRichEmpty, serializeRichToLexical } from "./richLexical";
|
|
9
10
|
|
|
@@ -35,7 +36,10 @@ export function useRichMentionComposer() {
|
|
|
35
36
|
const [tick, setTick] = useState(0);
|
|
36
37
|
const bump = useCallback(() => setTick((n) => n + 1), []);
|
|
37
38
|
|
|
38
|
-
|
|
39
|
+
// Searched on the server as the member types; nothing is asked for without an @token.
|
|
40
|
+
const { data: directory = [] } = useCommunityMentionDirectory(activeToken?.query);
|
|
41
|
+
// The community AI, listed first when it is on (community parity §8.2).
|
|
42
|
+
const { agent } = useCommunityAgent();
|
|
39
43
|
|
|
40
44
|
const members: MentionSuggestion[] = useMemo(
|
|
41
45
|
() =>
|
|
@@ -51,8 +55,17 @@ export function useRichMentionComposer() {
|
|
|
51
55
|
const suggestions: MentionSuggestion[] = useMemo(() => {
|
|
52
56
|
if (!activeToken) return [];
|
|
53
57
|
const q = activeToken.query.toLowerCase();
|
|
54
|
-
|
|
55
|
-
|
|
58
|
+
const matched = members.filter((m) => m.name.toLowerCase().includes(q)).slice(0, 6);
|
|
59
|
+
if (!agent || !agent.name.toLowerCase().includes(q)) return matched;
|
|
60
|
+
const agentSuggestion: MentionSuggestion = {
|
|
61
|
+
accountId: "",
|
|
62
|
+
agentId: agent.id,
|
|
63
|
+
name: agent.name,
|
|
64
|
+
avatarUrl: agent.avatarUrl,
|
|
65
|
+
level: 0,
|
|
66
|
+
};
|
|
67
|
+
return [agentSuggestion, ...matched];
|
|
68
|
+
}, [activeToken, members, agent]);
|
|
56
69
|
|
|
57
70
|
/** Recompute the active @query token from the current selection. */
|
|
58
71
|
const detectToken = useCallback(() => {
|
|
@@ -74,7 +87,7 @@ export function useRichMentionComposer() {
|
|
|
74
87
|
setActiveToken({ query, textNode, atOffset: caretOffset - query.length - 1, caretOffset });
|
|
75
88
|
}, []);
|
|
76
89
|
|
|
77
|
-
/** Fired on input/keyup/click/select
|
|
90
|
+
/** Fired on input/keyup/click/select: refresh emptiness + token. */
|
|
78
91
|
const onEditorChange = useCallback(() => {
|
|
79
92
|
bump();
|
|
80
93
|
detectToken();
|
|
@@ -124,7 +137,8 @@ export function useRichMentionComposer() {
|
|
|
124
137
|
range.deleteContents();
|
|
125
138
|
|
|
126
139
|
const chip = doc.createElement("span");
|
|
127
|
-
chip.setAttribute("data-
|
|
140
|
+
if (member.agentId) chip.setAttribute("data-agent-id", member.agentId);
|
|
141
|
+
else chip.setAttribute("data-account-id", member.accountId);
|
|
128
142
|
chip.setAttribute("data-mention-name", member.name);
|
|
129
143
|
chip.setAttribute("contenteditable", "false");
|
|
130
144
|
chip.textContent = `@${member.name}`;
|
|
@@ -184,6 +198,8 @@ export function useRichMentionComposer() {
|
|
|
184
198
|
isSuggesting: !!activeToken && suggestions.length > 0,
|
|
185
199
|
insertMention,
|
|
186
200
|
dismissSuggestions: () => setActiveToken(null),
|
|
201
|
+
/** The community AI when it is on and can be mentioned, else null. */
|
|
202
|
+
agent,
|
|
187
203
|
// Submission / state
|
|
188
204
|
isEmpty,
|
|
189
205
|
getBody,
|