@tribe-nest/forge 3.60.0 → 3.63.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -2
- package/src/contexts/CartContext.tsx +34 -1
- package/src/contexts/PublicAuthContext.tsx +15 -3
- package/src/data/queries/_tests/metaSignals.spec.tsx +130 -0
- package/src/data/queries/useAccount.ts +33 -2
- package/src/data/queries/useAnalytics.ts +6 -6
- package/src/data/queries/useCallChat.ts +2 -2
- package/src/data/queries/useChat.ts +10 -1
- package/src/data/queries/useCheckouts.ts +2 -1
- package/src/data/queries/useCoachingAvailability.ts +10 -1
- package/src/data/queries/useCommunities.ts +2 -0
- package/src/data/queries/useCommunity.ts +362 -18
- package/src/data/queries/useCommunityAgent.ts +247 -0
- package/src/data/queries/useCommunityCalendar.ts +150 -0
- package/src/data/queries/useCommunityClassroom.ts +75 -0
- package/src/data/queries/useCommunityMembers.ts +108 -0
- package/src/data/queries/useCommunityModeration.ts +327 -0
- package/src/data/queries/useCommunityWiki.ts +137 -0
- package/src/data/queries/useCourses.ts +2 -0
- package/src/data/queries/useEmailSignIn.ts +79 -0
- package/src/data/queries/useEvents.ts +6 -1
- package/src/data/queries/useForms.ts +5 -1
- package/src/data/queries/useLeadMagnet.ts +6 -2
- package/src/data/queries/useMyBookings.ts +24 -1
- package/src/data/queries/useMyTickets.ts +24 -1
- package/src/data/queries/useNotifications.ts +6 -2
- package/src/data/queries/useOrders.ts +2 -1
- package/src/data/queries/usePWA.ts +111 -35
- package/src/data/queries/useSiteApp.ts +130 -0
- package/src/data/queries/useSubscriptions.ts +6 -1
- package/src/i18n/de.json +746 -1
- package/src/i18n/en.json +746 -1
- package/src/index.ts +20 -0
- package/src/runtime/pages.spec.ts +5 -0
- package/src/runtime/pages.ts +32 -6
- package/src/seo/_tests/seo.spec.ts +326 -0
- package/src/seo/applySeoToHead.ts +98 -0
- package/src/seo/index.ts +9 -0
- package/src/seo/resolve.ts +211 -0
- package/src/seo/seoHead.ts +61 -0
- package/src/seo/siteSeoHead.ts +97 -0
- package/src/seo/types.ts +99 -0
- package/src/server/_tests/siteBootstrap.spec.ts +65 -0
- package/src/server/index.ts +33 -4
- package/src/types/models.ts +11 -0
- package/src/ui/analytics/ForgeAnalytics.tsx +134 -34
- package/src/ui/analytics/PageMetaPixel.tsx +23 -8
- package/src/ui/analytics/_tests/ForgeAnalytics.meta.spec.tsx +129 -0
- package/src/ui/analytics/_tests/ForgeAnalytics.webVitals.spec.tsx +129 -0
- package/src/ui/analytics/_tests/metaCommerceEvents.spec.tsx +159 -0
- package/src/ui/analytics/useMetaEvents.ts +78 -0
- package/src/ui/headless/_tests/communityAgent.spec.tsx +519 -0
- package/src/ui/headless/_tests/communityCalendar.spec.tsx +195 -0
- package/src/ui/headless/_tests/communityClassroom.spec.tsx +120 -0
- package/src/ui/headless/_tests/communityHarness.tsx +10 -3
- package/src/ui/headless/_tests/communityMembers.spec.tsx +316 -0
- package/src/ui/headless/_tests/communityPoll.spec.tsx +389 -0
- package/src/ui/headless/_tests/memberHomeBar.spec.ts +84 -0
- package/src/ui/headless/_tests/siteAppBar.spec.ts +83 -0
- package/src/ui/headless/_tests/siteAppHarness.ts +19 -0
- package/src/ui/headless/_tests/siteAppHome.spec.ts +226 -0
- package/src/ui/headless/_tests/siteAppPrompts.spec.ts +185 -0
- package/src/ui/headless/_tests/useCommunityJoin.spec.tsx +228 -14
- package/src/ui/headless/_tests/useCommunityMembership.spec.tsx +29 -1
- package/src/ui/headless/_tests/useCommunityModeration.spec.tsx +420 -0
- package/src/ui/headless/_tests/useCommunityReads.spec.tsx +11 -2
- package/src/ui/headless/_tests/useCommunityWiki.spec.tsx +224 -0
- package/src/ui/headless/_tests/useSiteApp.spec.tsx +52 -0
- package/src/ui/headless/_tests/wikiMediaImage.spec.tsx +132 -0
- package/src/ui/headless/auth/useEmailCodeSignIn.ts +186 -0
- package/src/ui/headless/auth/useSignupForm.ts +20 -1
- package/src/ui/headless/chat/useChatAttachmentUpload.ts +9 -6
- package/src/ui/headless/chat/useChatRoom.ts +35 -1
- package/src/ui/headless/communities/index.ts +41 -0
- package/src/ui/headless/communities/useCommunityCalendar.ts +156 -0
- package/src/ui/headless/communities/useCommunityClassroom.ts +84 -0
- package/src/ui/headless/communities/useCommunityHome.ts +3 -0
- package/src/ui/headless/communities/useCommunityJoin.ts +73 -23
- package/src/ui/headless/communities/useCommunityMembership.ts +48 -2
- package/src/ui/headless/communities/useCommunityWiki.ts +266 -0
- package/src/ui/headless/community/communityPoll.ts +297 -0
- package/src/ui/headless/community/countries.ts +123 -0
- package/src/ui/headless/community/index.ts +81 -1
- package/src/ui/headless/community/linkPreview.ts +71 -0
- package/src/ui/headless/community/richLexical.ts +27 -2
- package/src/ui/headless/community/useCommunityAgent.ts +285 -0
- package/src/ui/headless/community/useCommunityMembers.ts +175 -0
- package/src/ui/headless/community/useCommunityModeration.ts +300 -0
- package/src/ui/headless/community/useCommunitySpaceFeed.ts +22 -4
- package/src/ui/headless/community/useCommunityThread.ts +16 -3
- package/src/ui/headless/community/useMentionComposer.ts +17 -9
- package/src/ui/headless/community/useRichMentionComposer.ts +21 -5
- package/src/ui/headless/downloads/index.ts +8 -0
- package/src/ui/headless/downloads/useDownloadPage.ts +134 -0
- package/src/ui/headless/index.ts +13 -0
- package/src/ui/headless/memberHome/index.ts +10 -0
- package/src/ui/headless/memberHome/memberHomeBar.ts +91 -0
- package/src/ui/headless/memberHome/useMemberHomeNav.ts +56 -18
- package/src/ui/headless/portal/index.ts +57 -0
- package/src/ui/headless/portal/usePortal.ts +274 -0
- package/src/ui/headless/portal/usePortalFiles.ts +68 -0
- package/src/ui/headless/portal/usePortalMessages.ts +189 -0
- package/src/ui/headless/portal/usePortalPreview.ts +142 -0
- package/src/ui/headless/siteApp/appBar.ts +102 -0
- package/src/ui/headless/siteApp/appHome.ts +344 -0
- package/src/ui/headless/siteApp/appPrompts.ts +229 -0
- package/src/ui/headless/siteApp/index.ts +58 -0
- package/src/ui/headless/siteApp/useAppHome.ts +210 -0
- package/src/ui/headless/siteApp/useAppInstallRequest.ts +16 -0
- package/src/ui/headless/work/index.ts +2 -0
- package/src/ui/headless/work/useWorkPortal.ts +37 -26
- package/src/ui/index.ts +11 -0
- package/src/ui/styled/AccountDashboard.tsx +712 -352
- package/src/ui/styled/AiAgentWidget.tsx +13 -1
- package/src/ui/styled/ChatRoom.tsx +126 -16
- package/src/ui/styled/Checkout.tsx +8 -0
- package/src/ui/styled/CheckoutConfirmation.tsx +8 -0
- package/src/ui/styled/CoachingConfirmation.tsx +8 -0
- package/src/ui/styled/CoachingDetail.tsx +2 -0
- package/src/ui/styled/CourseConfirmation.tsx +5 -0
- package/src/ui/styled/CourseDetail.tsx +2 -0
- package/src/ui/styled/DownloadPage.tsx +263 -0
- package/src/ui/styled/EventConfirmation.tsx +10 -0
- package/src/ui/styled/EventDetail.tsx +10 -0
- package/src/ui/styled/FilmLibrary.tsx +22 -9
- package/src/ui/styled/MusicLinkPage.tsx +5 -5
- package/src/ui/styled/ProductDetail.tsx +12 -0
- package/src/ui/styled/PwaRegistration.tsx +5 -1
- package/src/ui/styled/SignupForm.tsx +18 -3
- package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +5 -5
- package/src/ui/styled/_tests/AccountNotifications.spec.tsx +135 -0
- package/src/ui/styled/_tests/DownloadPage.spec.tsx +68 -0
- package/src/ui/styled/app/AppBar.tsx +298 -0
- package/src/ui/styled/app/AppHome.tsx +1139 -0
- package/src/ui/styled/app/AppHomeMark.tsx +66 -0
- package/src/ui/styled/app/AppPrompts.tsx +464 -0
- package/src/ui/styled/app/_tests/AppBar.spec.tsx +96 -0
- package/src/ui/styled/app/_tests/AppHome.spec.tsx +209 -0
- package/src/ui/styled/app/_tests/AppPrompts.spec.tsx +232 -0
- package/src/ui/styled/app/index.ts +7 -0
- package/src/ui/styled/chat/ChatThreadPanel.tsx +29 -4
- package/src/ui/styled/chat/MessageBubble.tsx +43 -6
- package/src/ui/styled/communities/CommunityAbout.tsx +6 -1
- package/src/ui/styled/communities/CommunityCalendar.tsx +484 -0
- package/src/ui/styled/communities/CommunityClassroom.tsx +302 -0
- package/src/ui/styled/communities/CommunityHome.tsx +134 -11
- package/src/ui/styled/communities/CommunityJoinSheet.tsx +119 -19
- package/src/ui/styled/communities/CommunityMembershipMenu.tsx +168 -52
- package/src/ui/styled/communities/CommunityPublicWiki.tsx +75 -0
- package/src/ui/styled/communities/CommunityWiki.tsx +289 -0
- package/src/ui/styled/communities/MyMembershipsList.tsx +31 -5
- package/src/ui/styled/communities/_tests/CommunityAppHome.spec.tsx +92 -0
- package/src/ui/styled/communities/_tests/CommunityClassroomCalendar.spec.tsx +453 -0
- package/src/ui/styled/communities/_tests/CommunityMobile.spec.tsx +399 -0
- package/src/ui/styled/communities/_tests/CommunityWiki.spec.tsx +223 -0
- package/src/ui/styled/communities/index.ts +9 -0
- package/src/ui/styled/communities/shared.tsx +39 -11
- package/src/ui/styled/community/CommunityAgent.tsx +286 -0
- package/src/ui/styled/community/CommunityComposer.tsx +266 -15
- package/src/ui/styled/community/CommunityFeed.tsx +50 -1
- package/src/ui/styled/community/CommunityLinkPreview.tsx +159 -0
- package/src/ui/styled/community/CommunityMemberGlobe.tsx +171 -0
- package/src/ui/styled/community/CommunityMemberProfile.tsx +137 -4
- package/src/ui/styled/community/CommunityMembers.tsx +387 -0
- package/src/ui/styled/community/CommunityModeration.tsx +587 -0
- package/src/ui/styled/community/CommunityModerationState.tsx +127 -0
- package/src/ui/styled/community/CommunityModeratorMenu.tsx +317 -0
- package/src/ui/styled/community/CommunityNotifications.tsx +52 -6
- package/src/ui/styled/community/CommunityPollCard.tsx +250 -0
- package/src/ui/styled/community/CommunityPostCard.tsx +110 -34
- package/src/ui/styled/community/CommunityPostDetail.tsx +157 -71
- package/src/ui/styled/community/CommunityRichText.tsx +177 -32
- package/src/ui/styled/community/CommunitySpaces.tsx +49 -14
- package/src/ui/styled/community/_tests/CommunityAgent.spec.tsx +571 -0
- package/src/ui/styled/community/_tests/CommunityLinkPreview.spec.tsx +167 -0
- package/src/ui/styled/community/_tests/CommunityMembers.spec.tsx +137 -0
- package/src/ui/styled/community/_tests/CommunityModeration.spec.tsx +291 -0
- package/src/ui/styled/community/_tests/CommunityPoll.spec.tsx +386 -0
- package/src/ui/styled/community/index.ts +37 -2
- package/src/ui/styled/forge-utilities.css +234 -0
- package/src/ui/styled/members/CallRecordingActions.tsx +182 -0
- package/src/ui/styled/members/MemberCallsCalendar.tsx +13 -158
- package/src/ui/styled/members/MemberHomeLayout.tsx +295 -15
- package/src/ui/styled/members/MemberPostsFeed.tsx +3 -2
- package/src/ui/styled/members/index.ts +8 -0
- package/src/ui/styled/portal/PortalAccountSection.tsx +61 -0
- package/src/ui/styled/portal/PortalActivity.tsx +167 -0
- package/src/ui/styled/portal/PortalBilling.tsx +197 -0
- package/src/ui/styled/portal/PortalFiles.tsx +279 -0
- package/src/ui/styled/portal/PortalHome.tsx +401 -0
- package/src/ui/styled/portal/PortalMessages.tsx +300 -0
- package/src/ui/styled/portal/PortalPreviewBanner.tsx +75 -0
- package/src/ui/styled/portal/PortalProjects.tsx +407 -0
- package/src/ui/styled/portal/PortalShell.tsx +397 -0
- package/src/ui/styled/portal/_tests/PortalAppHome.spec.tsx +90 -0
- package/src/ui/styled/portal/_tests/PortalFiles.spec.tsx +159 -0
- package/src/ui/styled/portal/_tests/PortalHome.spec.tsx +148 -0
- package/src/ui/styled/portal/_tests/PortalMessages.spec.tsx +168 -0
- package/src/ui/styled/portal/_tests/PortalPreviewBanner.spec.tsx +132 -0
- package/src/ui/styled/portal/_tests/portalNav.spec.ts +123 -0
- package/src/ui/styled/portal/index.ts +13 -0
- package/src/ui/styled/portal/portalNav.ts +260 -0
- package/src/ui/styled/portal/shared.tsx +206 -0
- package/src/ui/styled/work/WorkInviteAccept.tsx +228 -168
- package/src/ui/styled/work/WorkPortalProjects.tsx +68 -120
- package/src/ui/styled/work/WorkProjectDocuments.tsx +99 -128
- package/src/ui/styled/work/WorkProjectInvoices.tsx +91 -86
- package/src/ui/styled/work/WorkProjectReport.tsx +11 -17
- package/src/ui/styled/work/WorkReportBody.tsx +223 -248
- package/src/ui/styled/work/WorkTaskAttachments.tsx +23 -29
- package/src/ui/styled/work/WorkTaskComments.tsx +75 -102
- package/src/ui/styled/work/WorkTaskDetail.tsx +48 -59
- package/src/ui/styled/work/WorkTokenReport.tsx +8 -16
- package/src/ui/styled/work/index.ts +1 -1
- package/src/utils/_tests/landing.spec.ts +39 -0
- package/src/utils/_tests/metaEvents.spec.ts +264 -0
- package/src/utils/_tests/structuredDataSeo.spec.ts +247 -0
- package/src/utils/landing.ts +36 -10
- package/src/utils/metaEvents.ts +302 -0
- package/src/utils/structuredData.ts +339 -1
|
@@ -0,0 +1,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
|
+
}
|
|
@@ -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();
|