@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
|
@@ -1,6 +1,15 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import {
|
|
3
|
+
keepPreviousData,
|
|
4
|
+
useInfiniteQuery,
|
|
5
|
+
useMutation,
|
|
6
|
+
useQuery,
|
|
7
|
+
useQueryClient,
|
|
8
|
+
type QueryClient,
|
|
9
|
+
} from "@tanstack/react-query";
|
|
2
10
|
import { useCommunityScope } from "../../contexts/CommunityContext";
|
|
3
11
|
import { useChatSocket } from "./useChat";
|
|
12
|
+
import type { CommunityAgentAuthor } from "./useCommunityAgent";
|
|
4
13
|
|
|
5
14
|
// ── Types (mirror apps/backend/src/routes/public/community + models) ─────────
|
|
6
15
|
|
|
@@ -25,6 +34,14 @@ export interface CommunitySpace {
|
|
|
25
34
|
memberCount?: number | null;
|
|
26
35
|
/** Whether the current member has joined this space (new-post notifications). */
|
|
27
36
|
isJoined?: boolean;
|
|
37
|
+
/** The level a member needs to read this space (1 = no level needed). */
|
|
38
|
+
minLevel?: number;
|
|
39
|
+
/**
|
|
40
|
+
* On the member list: the space is above the member's level. It is listed so
|
|
41
|
+
* they can see what their next level opens, but its feed stays closed (the
|
|
42
|
+
* server refuses it too).
|
|
43
|
+
*/
|
|
44
|
+
levelLocked?: boolean;
|
|
28
45
|
}
|
|
29
46
|
|
|
30
47
|
/** One roster row (GET /public/community/spaces/:id/members). */
|
|
@@ -80,7 +97,8 @@ export interface CommunityPost {
|
|
|
80
97
|
id: string;
|
|
81
98
|
profileId: string;
|
|
82
99
|
spaceId: string;
|
|
83
|
-
|
|
100
|
+
/** Null when the community AI wrote it (`agentAuthor` is then set). */
|
|
101
|
+
authorAccountId: string | null;
|
|
84
102
|
title: string | null;
|
|
85
103
|
/** Lexical editor state (JSON). Render with <CommunityRichText/>. */
|
|
86
104
|
body: unknown;
|
|
@@ -92,22 +110,119 @@ export interface CommunityPost {
|
|
|
92
110
|
lastActivityAt: string | null;
|
|
93
111
|
createdAt: string;
|
|
94
112
|
author: CommunityAuthor | null;
|
|
113
|
+
/** The community AI, when it wrote this post. `author` is then null. Absent on older servers. */
|
|
114
|
+
agentAuthor?: CommunityAgentAuthor | null;
|
|
95
115
|
media: CommunityMediaItem[];
|
|
96
116
|
reactions?: ReactionSummary[];
|
|
117
|
+
/**
|
|
118
|
+
* Moderation state. Only an author sees their own pending post; everyone else
|
|
119
|
+
* sees published posts only. Absent on rows from older servers: treat as published.
|
|
120
|
+
*/
|
|
121
|
+
status?: CommunityContentStatus;
|
|
122
|
+
/** Why a post was held (`approval_new_member`, `approval_all`, `keyword:<word>`), or why it was rejected. */
|
|
123
|
+
moderationReason?: string | null;
|
|
124
|
+
/** Set in the cache when a moderator removes the post in this session, so the undo stays in place. */
|
|
125
|
+
archivedAt?: string | null;
|
|
126
|
+
/**
|
|
127
|
+
* The post's poll, as this viewer sees it, or null when it has none. Absent
|
|
128
|
+
* on a post that arrived over the realtime socket (see `CommunityPollView`).
|
|
129
|
+
*/
|
|
130
|
+
poll?: CommunityPollView | null;
|
|
131
|
+
/**
|
|
132
|
+
* A card for the first link in the post, or null. Filled in the background
|
|
133
|
+
* shortly after the post is published, so a new post has none at first.
|
|
134
|
+
* Absent on rows from older servers.
|
|
135
|
+
*/
|
|
136
|
+
linkPreview?: CommunityLinkPreview | null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** A link card on a post (community parity §6.2). */
|
|
140
|
+
export interface CommunityLinkPreview {
|
|
141
|
+
url: string;
|
|
142
|
+
kind: "link" | "video";
|
|
143
|
+
provider: "youtube" | "vimeo" | "loom" | null;
|
|
144
|
+
siteName: string | null;
|
|
145
|
+
title: string | null;
|
|
146
|
+
description: string | null;
|
|
147
|
+
imageUrl: string | null;
|
|
148
|
+
/** The player address, for a video. Render only through `safeEmbedUrl`. */
|
|
149
|
+
embedUrl: string | null;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ── Polls (community parity §6.1) ────────────────────────────────────────────
|
|
153
|
+
|
|
154
|
+
/** When a poll's counts are shown: at once, once the member voted, or once it closed. */
|
|
155
|
+
export type CommunityPollResultsVisibility = "always" | "after_vote" | "after_close";
|
|
156
|
+
|
|
157
|
+
export const COMMUNITY_POLL_RESULTS_VISIBILITY: readonly CommunityPollResultsVisibility[] = [
|
|
158
|
+
"always",
|
|
159
|
+
"after_vote",
|
|
160
|
+
"after_close",
|
|
161
|
+
];
|
|
162
|
+
|
|
163
|
+
export interface CommunityPollOption {
|
|
164
|
+
id: string;
|
|
165
|
+
label: string;
|
|
166
|
+
position: number;
|
|
167
|
+
/** Null while this viewer may not see results yet. */
|
|
168
|
+
votes: number | null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** A post's poll as the viewer sees it. The post title is its question. */
|
|
172
|
+
export interface CommunityPollView {
|
|
173
|
+
id: string;
|
|
174
|
+
multipleChoice: boolean;
|
|
175
|
+
closesAt: string | null;
|
|
176
|
+
closed: boolean;
|
|
177
|
+
resultsVisibility: CommunityPollResultsVisibility;
|
|
178
|
+
/** Whether this viewer may see the counts now. */
|
|
179
|
+
resultsVisible: boolean;
|
|
180
|
+
/** Distinct members who voted. Null while results are hidden. */
|
|
181
|
+
totalVoters: number | null;
|
|
182
|
+
/** The option ids this viewer chose. Empty when they have not voted. */
|
|
183
|
+
myVotes: string[];
|
|
184
|
+
options: CommunityPollOption[];
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** The poll part of the create-post body. The question travels as the post `title`. */
|
|
188
|
+
export interface CommunityPollInput {
|
|
189
|
+
options: string[];
|
|
190
|
+
multipleChoice?: boolean;
|
|
191
|
+
/** ISO date, in the future. */
|
|
192
|
+
closesAt?: string | null;
|
|
193
|
+
resultsVisibility?: CommunityPollResultsVisibility;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Where a post or reply stands in moderation. */
|
|
197
|
+
export type CommunityContentStatus = "published" | "pending" | "rejected";
|
|
198
|
+
|
|
199
|
+
/** True when a post or reply is waiting for a moderator (only its author ever sees it). */
|
|
200
|
+
export function isCommunityContentPending(
|
|
201
|
+
item: { status?: CommunityContentStatus | string | null } | null | undefined,
|
|
202
|
+
): boolean {
|
|
203
|
+
return item?.status === "pending";
|
|
97
204
|
}
|
|
98
205
|
|
|
99
206
|
export interface CommunityReply {
|
|
100
207
|
id: string;
|
|
101
208
|
postId: string;
|
|
102
209
|
parentReplyId: string | null;
|
|
103
|
-
|
|
210
|
+
/** Null when the community AI wrote it (`agentAuthor` is then set). */
|
|
211
|
+
authorAccountId: string | null;
|
|
104
212
|
body: unknown;
|
|
105
213
|
bodyText: string | null;
|
|
106
214
|
reactionCount: number;
|
|
107
215
|
createdAt: string;
|
|
108
216
|
author: CommunityAuthor | null;
|
|
217
|
+
/** The community AI, when this reply is its answer. `author` is then null. */
|
|
218
|
+
agentAuthor?: CommunityAgentAuthor | null;
|
|
109
219
|
media: CommunityMediaItem[];
|
|
110
220
|
reactions?: ReactionSummary[];
|
|
221
|
+
/** See `CommunityPost.status`. */
|
|
222
|
+
status?: CommunityContentStatus;
|
|
223
|
+
moderationReason?: string | null;
|
|
224
|
+
/** Set in the cache when a moderator removes the reply in this session. */
|
|
225
|
+
archivedAt?: string | null;
|
|
111
226
|
}
|
|
112
227
|
|
|
113
228
|
/** Attachment input shape the community create endpoints accept. */
|
|
@@ -136,6 +251,20 @@ export interface CommunityMemberProfile {
|
|
|
136
251
|
createdAt: string;
|
|
137
252
|
firstName: string | null;
|
|
138
253
|
lastName: string | null;
|
|
254
|
+
/**
|
|
255
|
+
* Only on `/me`: whether this member moderates THIS community (the moderator
|
|
256
|
+
* role here, or an owner/admin of the profile). Display only; the server
|
|
257
|
+
* checks again on every moderation call.
|
|
258
|
+
*/
|
|
259
|
+
canModerate?: boolean;
|
|
260
|
+
/** Listed in the member directory (default true). */
|
|
261
|
+
showInDirectory?: boolean;
|
|
262
|
+
/** Counted on the member map, by country only (default false). */
|
|
263
|
+
showOnMap?: boolean;
|
|
264
|
+
/** Free text, display only. */
|
|
265
|
+
city?: string | null;
|
|
266
|
+
/** ISO 3166-1 alpha-2, or null. */
|
|
267
|
+
countryCode?: string | null;
|
|
139
268
|
recentPosts?: {
|
|
140
269
|
id: string;
|
|
141
270
|
spaceId: string;
|
|
@@ -158,17 +287,30 @@ export interface CommunityMemberProfile {
|
|
|
158
287
|
|
|
159
288
|
export type CommunityDigestCadence = "off" | "inapp_only" | "hourly" | "daily";
|
|
160
289
|
|
|
161
|
-
export type CommunityNotificationType = "mention" | "reply" | "reaction" | "new_post" | "mod";
|
|
290
|
+
export type CommunityNotificationType = "mention" | "reply" | "reaction" | "new_post" | "mod" | "level_up";
|
|
162
291
|
|
|
163
292
|
export interface CommunityNotificationItem {
|
|
164
293
|
id: string;
|
|
165
294
|
profileId: string;
|
|
166
295
|
accountId: string;
|
|
167
296
|
type: CommunityNotificationType | string;
|
|
168
|
-
entityType: "community_post" | "community_reply" | string | null;
|
|
297
|
+
entityType: "community_post" | "community_reply" | "community" | string | null;
|
|
169
298
|
entityId: string | null;
|
|
170
299
|
readAt: string | null;
|
|
171
300
|
createdAt: string;
|
|
301
|
+
/**
|
|
302
|
+
* Extra detail for some types. A `mod` notice about a post or reply carries
|
|
303
|
+
* the moderation outcome; a `mod` notice about the community (a mute) has none.
|
|
304
|
+
*/
|
|
305
|
+
data?: CommunityNotificationData | null;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export interface CommunityNotificationData {
|
|
309
|
+
outcome?: "approved" | "rejected";
|
|
310
|
+
reason?: string | null;
|
|
311
|
+
/** On a `level_up`: the level the member reached. */
|
|
312
|
+
level?: number;
|
|
313
|
+
[key: string]: unknown;
|
|
172
314
|
}
|
|
173
315
|
|
|
174
316
|
export interface CommunityLeaderboardEntry {
|
|
@@ -251,6 +393,9 @@ export const communityKeys = {
|
|
|
251
393
|
leaderboard: (communityId?: string, period?: string) => ["public-community-leaderboard", communityId, period] as const,
|
|
252
394
|
search: (communityId?: string, q?: string, spaceId?: string) =>
|
|
253
395
|
["public-community-search", communityId, q, spaceId ?? null] as const,
|
|
396
|
+
moderationQueue: (communityId?: string) => ["public-community-moderation-queue", communityId] as const,
|
|
397
|
+
moderationLog: (communityId?: string, page?: number) =>
|
|
398
|
+
["public-community-moderation-log", communityId, page] as const,
|
|
254
399
|
};
|
|
255
400
|
|
|
256
401
|
// ── Data hooks ───────────────────────────────────────────────────────────────
|
|
@@ -401,6 +546,8 @@ export interface CreateCommunityPostInput {
|
|
|
401
546
|
body: unknown;
|
|
402
547
|
attachments?: CommunityAttachmentInput[];
|
|
403
548
|
mentionedAccountIds?: string[];
|
|
549
|
+
/** Attach a poll. `title` is then its question, and required. */
|
|
550
|
+
poll?: CommunityPollInput;
|
|
404
551
|
}
|
|
405
552
|
|
|
406
553
|
/** Create a post in a space (POST /public/community/spaces/:id/posts). */
|
|
@@ -419,6 +566,41 @@ export function useCreateCommunityPost(spaceId?: string) {
|
|
|
419
566
|
});
|
|
420
567
|
}
|
|
421
568
|
|
|
569
|
+
export interface VoteCommunityPollInput {
|
|
570
|
+
postId: string;
|
|
571
|
+
/**
|
|
572
|
+
* The member's WHOLE answer: it replaces any earlier vote. An empty list
|
|
573
|
+
* withdraws the vote.
|
|
574
|
+
*/
|
|
575
|
+
optionIds: string[];
|
|
576
|
+
/** Cache hint: the feed the post sits in. Omitted, every loaded feed is patched. */
|
|
577
|
+
spaceId?: string;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* Vote on a post's poll (POST /public/community/posts/:id/poll/vote). Answers
|
|
582
|
+
* the updated poll, which lands in the feed and post caches. The server answers
|
|
583
|
+
* 409 once the poll has closed, 400 for an option that is not on it (or several
|
|
584
|
+
* on a single-choice poll), and 403 to a muted member or one without access.
|
|
585
|
+
*/
|
|
586
|
+
export function useVoteCommunityPoll() {
|
|
587
|
+
const { client, profileId, communityId } = useCommunityScope("useVoteCommunityPoll()");
|
|
588
|
+
const queryClient = useQueryClient();
|
|
589
|
+
return useMutation({
|
|
590
|
+
mutationFn: async (input: VoteCommunityPollInput) => {
|
|
591
|
+
const res = await client.post(`/public/community/posts/${input.postId}/poll/vote`, {
|
|
592
|
+
profileId,
|
|
593
|
+
communityId,
|
|
594
|
+
optionIds: input.optionIds,
|
|
595
|
+
});
|
|
596
|
+
return { poll: res.data as CommunityPollView, input };
|
|
597
|
+
},
|
|
598
|
+
onSuccess: ({ poll, input }) => {
|
|
599
|
+
setPostPoll(queryClient, { postId: input.postId, spaceId: input.spaceId }, poll);
|
|
600
|
+
},
|
|
601
|
+
});
|
|
602
|
+
}
|
|
603
|
+
|
|
422
604
|
export interface CreateCommunityReplyInput {
|
|
423
605
|
body: unknown;
|
|
424
606
|
parentReplyId?: string;
|
|
@@ -513,6 +695,17 @@ export function useMyCommunityProfile(enabled = true) {
|
|
|
513
695
|
});
|
|
514
696
|
}
|
|
515
697
|
|
|
698
|
+
/**
|
|
699
|
+
* Whether the signed-in member moderates THIS community (`canModerate` on
|
|
700
|
+
* `/me`), for showing the moderation tools. Display only: every moderation
|
|
701
|
+
* call is checked again by the server. Reads the same cache entry as
|
|
702
|
+
* `useMyCommunityProfile`, and stays false (with no request) until `enabled`.
|
|
703
|
+
*/
|
|
704
|
+
export function useCommunityCanModerate(enabled = true): boolean {
|
|
705
|
+
const { data } = useMyCommunityProfile(enabled);
|
|
706
|
+
return enabled && !!data?.canModerate;
|
|
707
|
+
}
|
|
708
|
+
|
|
516
709
|
/** Update display name / bio / avatar / digest cadence (PUT /public/community/me). */
|
|
517
710
|
export function useUpdateMyCommunityProfile() {
|
|
518
711
|
const { client, profileId, communityId } = useCommunityScope("useUpdateMyCommunityProfile()");
|
|
@@ -523,13 +716,24 @@ export function useUpdateMyCommunityProfile() {
|
|
|
523
716
|
bio?: string | null;
|
|
524
717
|
avatarUrl?: string | null;
|
|
525
718
|
digestCadence?: CommunityDigestCadence;
|
|
719
|
+
showInDirectory?: boolean;
|
|
720
|
+
showOnMap?: boolean;
|
|
721
|
+
city?: string | null;
|
|
722
|
+
countryCode?: string | null;
|
|
526
723
|
}) => {
|
|
527
724
|
const res = await client.put("/public/community/me", { profileId, communityId, ...input });
|
|
528
725
|
return res.data as CommunityMemberProfile;
|
|
529
726
|
},
|
|
530
727
|
onSuccess: (profile) => {
|
|
531
|
-
|
|
728
|
+
// Merged, not replaced: `/me` carries fields the PUT answer may not
|
|
729
|
+
// (`canModerate`), and dropping them would hide the moderator's tools.
|
|
730
|
+
queryClient.setQueryData(communityKeys.myProfile(communityId), (old: CommunityMemberProfile | undefined) =>
|
|
731
|
+
old ? { ...old, ...profile } : profile,
|
|
732
|
+
);
|
|
532
733
|
queryClient.invalidateQueries({ queryKey: communityKeys.member(communityId, profile?.accountId) });
|
|
734
|
+
// Directory and map visibility may have changed.
|
|
735
|
+
queryClient.invalidateQueries({ queryKey: ["public-community-directory", communityId] });
|
|
736
|
+
queryClient.invalidateQueries({ queryKey: ["public-community-member-map", communityId] });
|
|
533
737
|
},
|
|
534
738
|
});
|
|
535
739
|
}
|
|
@@ -606,24 +810,59 @@ export function useCommunityLeaderboard(period: CommunityLeaderboardPeriod = "al
|
|
|
606
810
|
});
|
|
607
811
|
}
|
|
608
812
|
|
|
813
|
+
/** How long the typed @query settles before the member search runs. */
|
|
814
|
+
export const MENTION_SEARCH_DEBOUNCE_MS = 150;
|
|
815
|
+
|
|
609
816
|
/**
|
|
610
|
-
*
|
|
611
|
-
*
|
|
612
|
-
*
|
|
613
|
-
* client-side.
|
|
817
|
+
* The member search behind @mention: the typed text as `q` (left out when
|
|
818
|
+
* blank, so a bare "@" lists the most active members), ten results, most
|
|
819
|
+
* recently active first.
|
|
614
820
|
*/
|
|
615
|
-
export function
|
|
821
|
+
export function mentionDirectoryParams(q: string | null | undefined): Record<string, string | number> {
|
|
822
|
+
const trimmed = (q ?? "").trim().slice(0, 80);
|
|
823
|
+
return { ...(trimmed ? { q: trimmed } : {}), limit: 10, sort: "active" };
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
/**
|
|
827
|
+
* Members for @mention autocomplete, searched on the server as the member
|
|
828
|
+
* types (GET /public/community/members), so every current member can be
|
|
829
|
+
* mentioned, not just the hundred with the most points. Pass the text after
|
|
830
|
+
* the "@"; pass `undefined` while no @token is active and nothing is asked
|
|
831
|
+
* for. Rows come back in the leaderboard entry shape the composers read.
|
|
832
|
+
*/
|
|
833
|
+
export function useCommunityMentionDirectory(query?: string | null) {
|
|
616
834
|
const { client, profileId, communityId } = useCommunityScope("useCommunityMentionDirectory()");
|
|
835
|
+
const active = query !== undefined && query !== null;
|
|
836
|
+
// null while no @token is open. The first text after "@" is searched at
|
|
837
|
+
// once; typing after that settles first, so a request goes per pause.
|
|
838
|
+
const [settled, setSettled] = useState<string | null>(null);
|
|
839
|
+
useEffect(() => {
|
|
840
|
+
if (!active) {
|
|
841
|
+
setSettled(null);
|
|
842
|
+
return;
|
|
843
|
+
}
|
|
844
|
+
const id = setTimeout(() => setSettled(query ?? ""), MENTION_SEARCH_DEBOUNCE_MS);
|
|
845
|
+
return () => clearTimeout(id);
|
|
846
|
+
}, [query, active]);
|
|
847
|
+
const params = mentionDirectoryParams(settled ?? query);
|
|
617
848
|
return useQuery<CommunityLeaderboardEntry[]>({
|
|
618
|
-
queryKey:
|
|
849
|
+
queryKey: ["public-community-mention-search", communityId, params] as const,
|
|
619
850
|
queryFn: async () => {
|
|
620
|
-
const res = await client.get("/public/community/
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
851
|
+
const res = await client.get("/public/community/members", { params: { profileId, communityId, ...params } });
|
|
852
|
+
const rows = (res.data?.data ?? []) as Array<CommunityLeaderboardEntry & { points?: number }>;
|
|
853
|
+
return rows.map((m) => ({
|
|
854
|
+
accountId: m.accountId,
|
|
855
|
+
displayName: m.displayName,
|
|
856
|
+
avatarUrl: m.avatarUrl,
|
|
857
|
+
points: m.points ?? 0,
|
|
858
|
+
level: m.level,
|
|
859
|
+
firstName: m.firstName,
|
|
860
|
+
lastName: m.lastName,
|
|
861
|
+
}));
|
|
624
862
|
},
|
|
625
|
-
|
|
626
|
-
|
|
863
|
+
placeholderData: keepPreviousData,
|
|
864
|
+
enabled: active && !!profileId && !!communityId && !!client,
|
|
865
|
+
staleTime: 30_000,
|
|
627
866
|
});
|
|
628
867
|
}
|
|
629
868
|
|
|
@@ -718,6 +957,32 @@ export function bumpFeedReplyCount(queryClient: QueryClient, spaceId: string | u
|
|
|
718
957
|
});
|
|
719
958
|
}
|
|
720
959
|
|
|
960
|
+
/**
|
|
961
|
+
* Put a post's updated poll into every cache that renders the post: its feed
|
|
962
|
+
* (the one named, or every loaded feed when none is) and the single-post read.
|
|
963
|
+
*/
|
|
964
|
+
export function setPostPoll(
|
|
965
|
+
queryClient: QueryClient,
|
|
966
|
+
target: { postId: string; spaceId?: string },
|
|
967
|
+
poll: CommunityPollView | null,
|
|
968
|
+
) {
|
|
969
|
+
const patchFeed = (old: any) => {
|
|
970
|
+
if (!old?.pages?.length) return old;
|
|
971
|
+
return {
|
|
972
|
+
...old,
|
|
973
|
+
pages: old.pages.map((page: any) => ({
|
|
974
|
+
...page,
|
|
975
|
+
data: page.data.map((p: CommunityPost) => (p.id === target.postId ? { ...p, poll } : p)),
|
|
976
|
+
})),
|
|
977
|
+
};
|
|
978
|
+
};
|
|
979
|
+
if (target.spaceId) queryClient.setQueryData(communityKeys.posts(target.spaceId), patchFeed);
|
|
980
|
+
else queryClient.setQueriesData({ queryKey: ["public-community-posts"] }, patchFeed);
|
|
981
|
+
queryClient.setQueryData(communityKeys.post(target.postId), (old: CommunityPost | undefined) =>
|
|
982
|
+
old ? { ...old, poll } : old,
|
|
983
|
+
);
|
|
984
|
+
}
|
|
985
|
+
|
|
721
986
|
/** Replace an entity's reaction summary in every cache that renders it. */
|
|
722
987
|
export function setReactionSummary(
|
|
723
988
|
queryClient: QueryClient,
|
|
@@ -796,3 +1061,82 @@ export function applyReactionDelta(
|
|
|
796
1061
|
});
|
|
797
1062
|
}
|
|
798
1063
|
}
|
|
1064
|
+
|
|
1065
|
+
/** The `communityContentHeld` socket payload: auto-moderation sent it back to the approval queue. */
|
|
1066
|
+
export interface CommunityContentHeldEvent {
|
|
1067
|
+
spaceId: string;
|
|
1068
|
+
entityType: "community_post" | "community_reply";
|
|
1069
|
+
postId: string | null;
|
|
1070
|
+
replyId: string | null;
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
/**
|
|
1074
|
+
* Take a post or reply that was just held for approval out of the open feeds
|
|
1075
|
+
* and threads. Everyone else loses it at once; its author keeps it, marked as
|
|
1076
|
+
* waiting for approval, exactly as the server shows them their own held work.
|
|
1077
|
+
* An open post that is held for a reader who is not its author becomes `null`,
|
|
1078
|
+
* which the thread renders as unavailable.
|
|
1079
|
+
*/
|
|
1080
|
+
export function applyCommunityContentHeld(
|
|
1081
|
+
queryClient: QueryClient,
|
|
1082
|
+
event: CommunityContentHeldEvent,
|
|
1083
|
+
viewerAccountId: string | null | undefined,
|
|
1084
|
+
) {
|
|
1085
|
+
const mine = (authorAccountId: string | null | undefined) => !!viewerAccountId && authorAccountId === viewerAccountId;
|
|
1086
|
+
const asPending = <T extends { status?: CommunityContentStatus }>(item: T): T => ({ ...item, status: "pending" });
|
|
1087
|
+
|
|
1088
|
+
if (event.entityType === "community_post" && event.postId) {
|
|
1089
|
+
const postId = event.postId;
|
|
1090
|
+
queryClient.setQueryData(communityKeys.posts(event.spaceId), (old: any) => {
|
|
1091
|
+
if (!old?.pages?.length) return old;
|
|
1092
|
+
return {
|
|
1093
|
+
...old,
|
|
1094
|
+
pages: old.pages.map((page: any) => {
|
|
1095
|
+
const had = page.data.some((p: CommunityPost) => p.id === postId);
|
|
1096
|
+
if (!had) return page;
|
|
1097
|
+
const data = page.data.flatMap((p: CommunityPost) =>
|
|
1098
|
+
p.id !== postId ? [p] : mine(p.authorAccountId) ? [asPending(p)] : [],
|
|
1099
|
+
);
|
|
1100
|
+
return { ...page, data, total: Math.max(0, page.total - (page.data.length - data.length)) };
|
|
1101
|
+
}),
|
|
1102
|
+
};
|
|
1103
|
+
});
|
|
1104
|
+
queryClient.setQueryData(communityKeys.post(postId), (old: CommunityPost | null | undefined) =>
|
|
1105
|
+
old ? (mine(old.authorAccountId) ? asPending(old) : null) : old,
|
|
1106
|
+
);
|
|
1107
|
+
return;
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
if (event.entityType === "community_reply" && event.postId && event.replyId) {
|
|
1111
|
+
const { postId, replyId } = event;
|
|
1112
|
+
let removed = false;
|
|
1113
|
+
queryClient.setQueryData(communityKeys.replies(postId), (old: any) => {
|
|
1114
|
+
if (!old?.pages?.length) return old;
|
|
1115
|
+
return {
|
|
1116
|
+
...old,
|
|
1117
|
+
pages: old.pages.map((page: any) => {
|
|
1118
|
+
if (!page.data.some((r: CommunityReply) => r.id === replyId)) return page;
|
|
1119
|
+
const data = page.data.flatMap((r: CommunityReply) =>
|
|
1120
|
+
r.id !== replyId ? [r] : mine(r.authorAccountId) ? [asPending(r)] : [],
|
|
1121
|
+
);
|
|
1122
|
+
if (data.length < page.data.length) removed = true;
|
|
1123
|
+
return { ...page, data, total: Math.max(0, page.total - (page.data.length - data.length)) };
|
|
1124
|
+
}),
|
|
1125
|
+
};
|
|
1126
|
+
});
|
|
1127
|
+
if (removed) {
|
|
1128
|
+
const drop = (p: CommunityPost) => ({ ...p, replyCount: Math.max(0, (p.replyCount ?? 0) - 1) });
|
|
1129
|
+
queryClient.setQueryData(communityKeys.post(postId), (old: CommunityPost | null | undefined) => (old ? drop(old) : old));
|
|
1130
|
+
queryClient.setQueryData(communityKeys.posts(event.spaceId), (old: any) => {
|
|
1131
|
+
if (!old?.pages?.length) return old;
|
|
1132
|
+
return {
|
|
1133
|
+
...old,
|
|
1134
|
+
pages: old.pages.map((page: any) => ({
|
|
1135
|
+
...page,
|
|
1136
|
+
data: page.data.map((p: CommunityPost) => (p.id === postId ? drop(p) : p)),
|
|
1137
|
+
})),
|
|
1138
|
+
};
|
|
1139
|
+
});
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
}
|