@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,266 @@
|
|
|
1
|
+
import { useMemo, useState } from "react";
|
|
2
|
+
import { useCommunityUpgradeOpener, useOptionalCommunityContext } from "../../../contexts/CommunityContext";
|
|
3
|
+
import { isNotFound } from "../../../data/queries/useCommunities";
|
|
4
|
+
import {
|
|
5
|
+
useCommunityWikiMediaUrl,
|
|
6
|
+
useCommunityWikiPageContent,
|
|
7
|
+
useCommunityWikiPages,
|
|
8
|
+
type CommunityWikiPageSummary,
|
|
9
|
+
} from "../../../data/queries/useCommunityWiki";
|
|
10
|
+
import type { LexicalNodeJson } from "../community/lexical";
|
|
11
|
+
import { communityAboutPath } from "./paths";
|
|
12
|
+
|
|
13
|
+
// ── Pure helpers (unit-tested; no React) ─────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
/** How a wiki image's `src` is shown, if at all. */
|
|
16
|
+
export type WikiImageSource = { kind: "url"; url: string } | { kind: "private"; mediaId: string } | null;
|
|
17
|
+
|
|
18
|
+
const PRIVATE_MEDIA_PREFIX = "kbmedia:";
|
|
19
|
+
const MEDIA_ID = /^[A-Za-z0-9_-]{1,128}$/;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Read a Knowledge Base image's `src`. A plain http(s) address (or a site
|
|
23
|
+
* path) is shown as it is. `kbmedia:<id>` is a PRIVATE image, shown through a
|
|
24
|
+
* short-lived signed read that only a viewer of the page can get. Anything
|
|
25
|
+
* else is never rendered: not `javascript:`, not `data:`, not a malformed id.
|
|
26
|
+
*/
|
|
27
|
+
export function wikiImageSource(src: unknown): WikiImageSource {
|
|
28
|
+
if (typeof src !== "string") return null;
|
|
29
|
+
const value = src.trim();
|
|
30
|
+
if (/^https?:\/\//i.test(value) || (value.startsWith("/") && !value.startsWith("//")))
|
|
31
|
+
return { kind: "url", url: value };
|
|
32
|
+
if (value.startsWith(PRIVATE_MEDIA_PREFIX)) {
|
|
33
|
+
const mediaId = value.slice(PRIVATE_MEDIA_PREFIX.length);
|
|
34
|
+
return MEDIA_ID.test(mediaId) ? { kind: "private", mediaId } : null;
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type WikiMediaImageStatus = "loading" | "ready" | "unavailable";
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Where a private image stands, from the signed-read request and the addresses
|
|
43
|
+
* the browser failed to load. The first failure (usually an expired address)
|
|
44
|
+
* asks for a fresh one; once that re-request has been made
|
|
45
|
+
* (`retriedAfterFailure`), a failed address reads as unavailable, so a broken
|
|
46
|
+
* file cannot loop.
|
|
47
|
+
*/
|
|
48
|
+
export function wikiMediaImageStatus(input: {
|
|
49
|
+
isLoading: boolean;
|
|
50
|
+
isError: boolean;
|
|
51
|
+
url: string | null;
|
|
52
|
+
failedUrls: ReadonlySet<string>;
|
|
53
|
+
retriedAfterFailure: boolean;
|
|
54
|
+
}): WikiMediaImageStatus {
|
|
55
|
+
if (input.isError) return "unavailable";
|
|
56
|
+
if (!input.url) return input.isLoading ? "loading" : "unavailable";
|
|
57
|
+
if (input.failedUrls.has(input.url)) return input.retriedAfterFailure ? "unavailable" : "loading";
|
|
58
|
+
return "ready";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** One entry of a page's table of contents. `id` is the heading's anchor. */
|
|
62
|
+
export interface CommunityWikiTocEntry {
|
|
63
|
+
id: string;
|
|
64
|
+
text: string;
|
|
65
|
+
/** 1 for h1 through 6 for h6. */
|
|
66
|
+
level: number;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** A table of contents is shown once a page has this many headings. */
|
|
70
|
+
export const WIKI_TOC_MIN_HEADINGS = 2;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The page list split the way a renderer draws it: every page in order, the
|
|
74
|
+
* ones this viewer can open, and the ones listed but locked to a tier they do
|
|
75
|
+
* not have. `publicOnly` keeps just the public pages (the About page, which a
|
|
76
|
+
* visitor and a member see alike).
|
|
77
|
+
*/
|
|
78
|
+
export function shapeWikiList(pages: CommunityWikiPageSummary[] | undefined, opts: { publicOnly?: boolean } = {}) {
|
|
79
|
+
const all = (Array.isArray(pages) ? pages : []).filter((page) => !opts.publicOnly || page.isPublic);
|
|
80
|
+
return {
|
|
81
|
+
all,
|
|
82
|
+
readable: all.filter((page) => !page.locked),
|
|
83
|
+
locked: all.filter((page) => page.locked),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* What opens a locked page, for the copy beside it: the level the member has
|
|
89
|
+
* not reached yet (null when the level is not what is missing) and the names
|
|
90
|
+
* of the tiers that open it (empty when none are named).
|
|
91
|
+
*/
|
|
92
|
+
export function wikiUnlockParts(page: Pick<CommunityWikiPageSummary, "unlockLevel" | "unlockTiers">): {
|
|
93
|
+
level: number | null;
|
|
94
|
+
tiers: string;
|
|
95
|
+
} {
|
|
96
|
+
const level = typeof page.unlockLevel === "number" && page.unlockLevel > 0 ? page.unlockLevel : null;
|
|
97
|
+
const tiers = (page.unlockTiers ?? []).map((tier) => tier.name).filter(Boolean).join(", ");
|
|
98
|
+
return { level, tiers };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The page the Wiki tab opens on when the address names none: the first page
|
|
103
|
+
* this viewer can read, in the wiki's order. Null when every page is locked or
|
|
104
|
+
* there are none.
|
|
105
|
+
*/
|
|
106
|
+
export function defaultWikiPageSlug(pages: CommunityWikiPageSummary[] | undefined): string | null {
|
|
107
|
+
return (Array.isArray(pages) ? pages : []).find((page) => !page.locked)?.slug ?? null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Lexical JSON as a root node. The Knowledge Base stores the editor state as an
|
|
112
|
+
* object, but a string of JSON is accepted too, so a page never renders empty
|
|
113
|
+
* because of how its content was serialised.
|
|
114
|
+
*/
|
|
115
|
+
export function parseWikiContent(content: unknown): { root: LexicalNodeJson } | null {
|
|
116
|
+
let value = content;
|
|
117
|
+
if (typeof value === "string") {
|
|
118
|
+
try {
|
|
119
|
+
value = JSON.parse(value);
|
|
120
|
+
} catch {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (!value || typeof value !== "object") return null;
|
|
125
|
+
const maybe = value as { root?: LexicalNodeJson; children?: LexicalNodeJson[] };
|
|
126
|
+
if (maybe.root && typeof maybe.root === "object") return { root: maybe.root };
|
|
127
|
+
if (Array.isArray(maybe.children)) return { root: maybe as LexicalNodeJson };
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** The plain text of a node and everything under it. */
|
|
132
|
+
export function lexicalNodeText(node: LexicalNodeJson): string {
|
|
133
|
+
if (typeof node.text === "string") return node.text;
|
|
134
|
+
return (node.children ?? []).map(lexicalNodeText).join("");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** A URL-safe anchor for a heading ("Getting started!" to "getting-started"). */
|
|
138
|
+
export function wikiHeadingSlug(text: string): string {
|
|
139
|
+
const slug = text
|
|
140
|
+
.normalize("NFKD")
|
|
141
|
+
// Drop the accents NFKD split off ("Über" to "Uber").
|
|
142
|
+
.replace(/\p{M}/gu, "")
|
|
143
|
+
.toLowerCase()
|
|
144
|
+
.replace(/[^\p{L}\p{N}]+/gu, "-")
|
|
145
|
+
.replace(/^-+|-+$/g, "");
|
|
146
|
+
return slug || "section";
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The table of contents: every top-level heading in document order, each with
|
|
151
|
+
* a unique anchor (a repeated title gets `-2`, `-3`). The renderer gives the
|
|
152
|
+
* headings these same ids in the same order, which is why both read the root's
|
|
153
|
+
* direct children only. Empty headings are skipped.
|
|
154
|
+
*/
|
|
155
|
+
export function extractWikiToc(content: unknown): CommunityWikiTocEntry[] {
|
|
156
|
+
const parsed = parseWikiContent(content);
|
|
157
|
+
if (!parsed) return [];
|
|
158
|
+
const used = new Map<string, number>();
|
|
159
|
+
const toc: CommunityWikiTocEntry[] = [];
|
|
160
|
+
for (const node of parsed.root.children ?? []) {
|
|
161
|
+
if (node.type !== "heading") continue;
|
|
162
|
+
const text = lexicalNodeText(node).trim();
|
|
163
|
+
if (!text) continue;
|
|
164
|
+
const base = wikiHeadingSlug(text);
|
|
165
|
+
const seen = used.get(base) ?? 0;
|
|
166
|
+
used.set(base, seen + 1);
|
|
167
|
+
const tag = typeof node.tag === "string" ? node.tag : "h2";
|
|
168
|
+
const level = Math.min(6, Math.max(1, Number(tag.replace(/^h/, "")) || 2));
|
|
169
|
+
toc.push({ id: seen ? `${base}-${seen + 1}` : base, text, level });
|
|
170
|
+
}
|
|
171
|
+
return toc;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ── Hooks ────────────────────────────────────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* A PRIVATE wiki image (`kbmedia:<id>`) on the page `pageSlug`: its signed
|
|
178
|
+
* address, re-requested before it expires, and again once if the browser
|
|
179
|
+
* fails to load it (`onImageError`). Only the wiki page view uses this: posts
|
|
180
|
+
* never carry private Knowledge Base media.
|
|
181
|
+
*/
|
|
182
|
+
export function useWikiMediaImage(pageSlug: string | undefined, mediaId: string | undefined) {
|
|
183
|
+
const query = useCommunityWikiMediaUrl(pageSlug, mediaId);
|
|
184
|
+
const [failedUrls, setFailedUrls] = useState<ReadonlySet<string>>(() => new Set());
|
|
185
|
+
const [retriedAfterFailure, setRetriedAfterFailure] = useState(false);
|
|
186
|
+
const url = query.data?.url ?? null;
|
|
187
|
+
const status = wikiMediaImageStatus({
|
|
188
|
+
isLoading: query.isLoading || query.isFetching,
|
|
189
|
+
isError: query.isError,
|
|
190
|
+
url,
|
|
191
|
+
failedUrls,
|
|
192
|
+
retriedAfterFailure,
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
return {
|
|
196
|
+
status,
|
|
197
|
+
url: status === "ready" ? url : null,
|
|
198
|
+
/** Call from the <img>'s onError: an expired address is replaced once. */
|
|
199
|
+
onImageError: () => {
|
|
200
|
+
if (!url || failedUrls.has(url)) return;
|
|
201
|
+
setFailedUrls((prev) => new Set(prev).add(url));
|
|
202
|
+
if (retriedAfterFailure) return;
|
|
203
|
+
void query.refetch().finally(() => setRetriedAfterFailure(true));
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export interface UseCommunityWikiOptions {
|
|
209
|
+
/** Keep only the public pages (the About page). */
|
|
210
|
+
publicOnly?: boolean;
|
|
211
|
+
enabled?: boolean;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Headless wiki page list for the surrounding community: every page this
|
|
216
|
+
* viewer may see, the readable and locked ones, the page to open by default,
|
|
217
|
+
* and how to unlock a locked one. `unlock` opens the community's upgrade sheet
|
|
218
|
+
* with the page's first unlocking tier when a community home provides one;
|
|
219
|
+
* otherwise `unlockHref` is the About page with that tier preselected.
|
|
220
|
+
*/
|
|
221
|
+
export function useCommunityWiki(opts: UseCommunityWikiOptions = {}) {
|
|
222
|
+
const community = useOptionalCommunityContext();
|
|
223
|
+
const opener = useCommunityUpgradeOpener();
|
|
224
|
+
const query = useCommunityWikiPages({ enabled: opts.enabled });
|
|
225
|
+
const shaped = useMemo(
|
|
226
|
+
() => shapeWikiList(query.data, { publicOnly: opts.publicOnly }),
|
|
227
|
+
[query.data, opts.publicOnly],
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
return {
|
|
231
|
+
pages: shaped.all,
|
|
232
|
+
readablePages: shaped.readable,
|
|
233
|
+
lockedPages: shaped.locked,
|
|
234
|
+
/** The page to open when the address names none. */
|
|
235
|
+
defaultSlug: defaultWikiPageSlug(shaped.all),
|
|
236
|
+
isLoading: query.isLoading,
|
|
237
|
+
isError: query.isError && !isNotFound(query.error),
|
|
238
|
+
/** Open the upgrade sheet for a locked page, when a community home provides one. */
|
|
239
|
+
unlock: opener ? (page: CommunityWikiPageSummary) => opener.openUpgrade(page.unlockTiers[0]?.id ?? null) : null,
|
|
240
|
+
/** Where a locked page's upgrade leads when no upgrade sheet is on hand. */
|
|
241
|
+
unlockHref: (page: CommunityWikiPageSummary) =>
|
|
242
|
+
community?.slug ? communityAboutPath(community.slug, { tier: page.unlockTiers[0]?.id }) : null,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Headless wiki page: its content (parsed), the table of contents and whether
|
|
248
|
+
* to show it, and `notFound` when this viewer may not read the page (locked,
|
|
249
|
+
* hidden or gone: the server does not say which, by design).
|
|
250
|
+
*/
|
|
251
|
+
export function useCommunityWikiPage(pageSlug: string | undefined, opts: { enabled?: boolean } = {}) {
|
|
252
|
+
const query = useCommunityWikiPageContent(pageSlug, opts);
|
|
253
|
+
const content = useMemo(() => parseWikiContent(query.data?.content), [query.data]);
|
|
254
|
+
const toc = useMemo(() => extractWikiToc(content), [content]);
|
|
255
|
+
return {
|
|
256
|
+
page: query.data ?? null,
|
|
257
|
+
content,
|
|
258
|
+
toc,
|
|
259
|
+
showToc: toc.length >= WIKI_TOC_MIN_HEADINGS,
|
|
260
|
+
/** Anchor ids for the page's headings, in order, for the renderer. */
|
|
261
|
+
headingIds: toc.map((entry) => entry.id),
|
|
262
|
+
isLoading: query.isLoading,
|
|
263
|
+
notFound: isNotFound(query.error),
|
|
264
|
+
isError: query.isError && !isNotFound(query.error),
|
|
265
|
+
};
|
|
266
|
+
}
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import { useEffect, useMemo, useState } from "react";
|
|
2
|
+
import {
|
|
3
|
+
COMMUNITY_POLL_RESULTS_VISIBILITY,
|
|
4
|
+
useVoteCommunityPoll,
|
|
5
|
+
type CommunityPollInput,
|
|
6
|
+
type CommunityPollResultsVisibility,
|
|
7
|
+
type CommunityPollView,
|
|
8
|
+
type CommunityPost,
|
|
9
|
+
} from "../../../data/queries/useCommunity";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Polls on community posts (community parity §6.1). A poll rides on a post:
|
|
13
|
+
* the post title is its question, and the poll carries 2 to 10 options. The
|
|
14
|
+
* limits below mirror the server's create schema, so a draft that passes here
|
|
15
|
+
* is not refused there for its shape.
|
|
16
|
+
*/
|
|
17
|
+
export const COMMUNITY_POLL_LIMITS = {
|
|
18
|
+
minOptions: 2,
|
|
19
|
+
maxOptions: 10,
|
|
20
|
+
optionMaxLength: 120,
|
|
21
|
+
questionMaxLength: 200,
|
|
22
|
+
} as const;
|
|
23
|
+
|
|
24
|
+
// ── Draft (the composer) ─────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
export interface CommunityPollDraft {
|
|
27
|
+
/** The question. Sent as the post title. */
|
|
28
|
+
question: string;
|
|
29
|
+
options: string[];
|
|
30
|
+
multipleChoice: boolean;
|
|
31
|
+
/** A `datetime-local` value (local time, no zone), or "" for no close. */
|
|
32
|
+
closesAt: string;
|
|
33
|
+
resultsVisibility: CommunityPollResultsVisibility;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const emptyPollDraft = (): CommunityPollDraft => ({
|
|
37
|
+
question: "",
|
|
38
|
+
options: ["", ""],
|
|
39
|
+
multipleChoice: false,
|
|
40
|
+
closesAt: "",
|
|
41
|
+
resultsVisibility: "after_vote",
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
export type CommunityPollQuestionError = "question_required" | "question_too_long";
|
|
45
|
+
export type CommunityPollOptionError = "option_empty" | "option_too_long" | "option_duplicate";
|
|
46
|
+
export type CommunityPollOptionsError = "options_too_few" | "options_too_many";
|
|
47
|
+
export type CommunityPollClosesError = "closes_invalid" | "closes_in_past" | "closes_required";
|
|
48
|
+
|
|
49
|
+
export interface CommunityPollDraftErrors {
|
|
50
|
+
question: CommunityPollQuestionError | null;
|
|
51
|
+
/** One entry per option input, in order. */
|
|
52
|
+
options: (CommunityPollOptionError | null)[];
|
|
53
|
+
/** About the list as a whole (how many options). */
|
|
54
|
+
optionsList: CommunityPollOptionsError | null;
|
|
55
|
+
closesAt: CommunityPollClosesError | null;
|
|
56
|
+
resultsVisibility: "visibility_invalid" | null;
|
|
57
|
+
valid: boolean;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** A `datetime-local` value as a Date, or null when it does not parse. */
|
|
61
|
+
export function parsePollCloseTime(value: string): Date | null {
|
|
62
|
+
if (!value.trim()) return null;
|
|
63
|
+
const date = new Date(value);
|
|
64
|
+
return Number.isNaN(date.getTime()) ? null : date;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Check a draft against the server's limits: a question, 2 to 10 options of
|
|
69
|
+
* 1 to 120 characters each, no two alike (compared trimmed and ignoring case,
|
|
70
|
+
* as the server de-duplicates), and a close time, when given, in the future.
|
|
71
|
+
* A poll whose results wait for it to close must have a close time.
|
|
72
|
+
*/
|
|
73
|
+
export function validatePollDraft(draft: CommunityPollDraft, now: number = Date.now()): CommunityPollDraftErrors {
|
|
74
|
+
const question = draft.question.trim();
|
|
75
|
+
const questionError: CommunityPollQuestionError | null = !question
|
|
76
|
+
? "question_required"
|
|
77
|
+
: question.length > COMMUNITY_POLL_LIMITS.questionMaxLength
|
|
78
|
+
? "question_too_long"
|
|
79
|
+
: null;
|
|
80
|
+
|
|
81
|
+
const seen = new Set<string>();
|
|
82
|
+
const options = draft.options.map((raw): CommunityPollOptionError | null => {
|
|
83
|
+
const label = raw.trim();
|
|
84
|
+
if (!label) return "option_empty";
|
|
85
|
+
if (label.length > COMMUNITY_POLL_LIMITS.optionMaxLength) return "option_too_long";
|
|
86
|
+
const key = label.toLowerCase();
|
|
87
|
+
if (seen.has(key)) return "option_duplicate";
|
|
88
|
+
seen.add(key);
|
|
89
|
+
return null;
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const optionsList: CommunityPollOptionsError | null =
|
|
93
|
+
draft.options.length < COMMUNITY_POLL_LIMITS.minOptions
|
|
94
|
+
? "options_too_few"
|
|
95
|
+
: draft.options.length > COMMUNITY_POLL_LIMITS.maxOptions
|
|
96
|
+
? "options_too_many"
|
|
97
|
+
: null;
|
|
98
|
+
|
|
99
|
+
let closesAt: CommunityPollClosesError | null = null;
|
|
100
|
+
if (draft.closesAt.trim()) {
|
|
101
|
+
const date = parsePollCloseTime(draft.closesAt);
|
|
102
|
+
if (!date) closesAt = "closes_invalid";
|
|
103
|
+
else if (date.getTime() <= now) closesAt = "closes_in_past";
|
|
104
|
+
} else if (draft.resultsVisibility === "after_close") {
|
|
105
|
+
// Results shown "when the poll closes" on a poll that never closes would
|
|
106
|
+
// never be shown to a member at all.
|
|
107
|
+
closesAt = "closes_required";
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const resultsVisibility = COMMUNITY_POLL_RESULTS_VISIBILITY.includes(draft.resultsVisibility)
|
|
111
|
+
? null
|
|
112
|
+
: ("visibility_invalid" as const);
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
question: questionError,
|
|
116
|
+
options,
|
|
117
|
+
optionsList,
|
|
118
|
+
closesAt,
|
|
119
|
+
resultsVisibility,
|
|
120
|
+
valid: !questionError && options.every((e) => !e) && !optionsList && !closesAt && !resultsVisibility,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** The create-post fields a draft becomes: the question as `title`, and the `poll` body. */
|
|
125
|
+
export function buildPollPayload(draft: CommunityPollDraft): { title: string; poll: CommunityPollInput } {
|
|
126
|
+
const close = parsePollCloseTime(draft.closesAt);
|
|
127
|
+
return {
|
|
128
|
+
title: draft.question.trim(),
|
|
129
|
+
poll: {
|
|
130
|
+
options: draft.options.map((o) => o.trim()),
|
|
131
|
+
multipleChoice: draft.multipleChoice,
|
|
132
|
+
closesAt: close ? close.toISOString() : null,
|
|
133
|
+
resultsVisibility: draft.resultsVisibility,
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The composer's poll state: the draft, adding and removing options within
|
|
140
|
+
* 2 to 10, and the errors (shown once the member has tried to post, so an
|
|
141
|
+
* untouched form does not open in red).
|
|
142
|
+
*/
|
|
143
|
+
export function useCommunityPollDraft() {
|
|
144
|
+
const [draft, setDraft] = useState<CommunityPollDraft>(emptyPollDraft);
|
|
145
|
+
const [showErrors, setShowErrors] = useState(false);
|
|
146
|
+
const errors = useMemo(() => validatePollDraft(draft), [draft]);
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
draft,
|
|
150
|
+
errors,
|
|
151
|
+
/** True once a submit was attempted: render `errors` from then on. */
|
|
152
|
+
showErrors,
|
|
153
|
+
canAddOption: draft.options.length < COMMUNITY_POLL_LIMITS.maxOptions,
|
|
154
|
+
canRemoveOption: draft.options.length > COMMUNITY_POLL_LIMITS.minOptions,
|
|
155
|
+
setQuestion: (question: string) => setDraft((d) => ({ ...d, question })),
|
|
156
|
+
setOption: (index: number, value: string) =>
|
|
157
|
+
setDraft((d) => ({ ...d, options: d.options.map((o, i) => (i === index ? value : o)) })),
|
|
158
|
+
addOption: () =>
|
|
159
|
+
setDraft((d) =>
|
|
160
|
+
d.options.length >= COMMUNITY_POLL_LIMITS.maxOptions ? d : { ...d, options: [...d.options, ""] },
|
|
161
|
+
),
|
|
162
|
+
removeOption: (index: number) =>
|
|
163
|
+
setDraft((d) =>
|
|
164
|
+
d.options.length <= COMMUNITY_POLL_LIMITS.minOptions
|
|
165
|
+
? d
|
|
166
|
+
: { ...d, options: d.options.filter((_, i) => i !== index) },
|
|
167
|
+
),
|
|
168
|
+
setMultipleChoice: (multipleChoice: boolean) => setDraft((d) => ({ ...d, multipleChoice })),
|
|
169
|
+
setClosesAt: (closesAt: string) => setDraft((d) => ({ ...d, closesAt })),
|
|
170
|
+
setResultsVisibility: (resultsVisibility: CommunityPollResultsVisibility) =>
|
|
171
|
+
setDraft((d) => ({ ...d, resultsVisibility })),
|
|
172
|
+
/** Mark a submit attempt; returns whether the draft may be sent. */
|
|
173
|
+
attempt: () => {
|
|
174
|
+
setShowErrors(true);
|
|
175
|
+
return validatePollDraft(draft).valid;
|
|
176
|
+
},
|
|
177
|
+
reset: () => {
|
|
178
|
+
setDraft(emptyPollDraft());
|
|
179
|
+
setShowErrors(false);
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ── Reading and voting ───────────────────────────────────────────────────────
|
|
185
|
+
|
|
186
|
+
/** A share of the voters, as a whole percentage. A multiple-choice poll's shares can sum past 100. */
|
|
187
|
+
export function pollOptionPercent(votes: number | null, totalVoters: number | null): number | null {
|
|
188
|
+
if (votes === null || totalVoters === null) return null;
|
|
189
|
+
if (totalVoters <= 0) return 0;
|
|
190
|
+
return Math.round((votes / totalVoters) * 100);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Why the counts are hidden, when they are. */
|
|
194
|
+
export type CommunityPollHiddenReason = "after_vote" | "after_close" | null;
|
|
195
|
+
|
|
196
|
+
export function pollHiddenReason(poll: CommunityPollView): CommunityPollHiddenReason {
|
|
197
|
+
if (poll.resultsVisible) return null;
|
|
198
|
+
return poll.resultsVisibility === "after_close" ? "after_close" : "after_vote";
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** What a failed vote means for the member. */
|
|
202
|
+
export type CommunityPollVoteError = "closed" | "invalid" | "forbidden" | "generic";
|
|
203
|
+
|
|
204
|
+
export function pollVoteErrorCode(error: unknown): CommunityPollVoteError {
|
|
205
|
+
const status = (error as { response?: { status?: number } } | null)?.response?.status;
|
|
206
|
+
if (status === 409) return "closed";
|
|
207
|
+
if (status === 400) return "invalid";
|
|
208
|
+
if (status === 403 || status === 401) return "forbidden";
|
|
209
|
+
return "generic";
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Two answers are the same answer when they hold the same options, in any order. */
|
|
213
|
+
export function sameAnswer(a: readonly string[], b: readonly string[]): boolean {
|
|
214
|
+
if (a.length !== b.length) return false;
|
|
215
|
+
const set = new Set(a);
|
|
216
|
+
return b.every((id) => set.has(id));
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Headless poll on one post: the member's selection (starting from their
|
|
221
|
+
* current vote), picking options (one on a single-choice poll, several on a
|
|
222
|
+
* multiple-choice one), sending the answer, changing it and withdrawing it.
|
|
223
|
+
* `disabled` (a post still waiting for approval) keeps it read-only.
|
|
224
|
+
*/
|
|
225
|
+
export function useCommunityPoll(post: Pick<CommunityPost, "id" | "spaceId" | "poll">, opts: { disabled?: boolean } = {}) {
|
|
226
|
+
const poll = post.poll ?? null;
|
|
227
|
+
const vote = useVoteCommunityPoll();
|
|
228
|
+
const myVotes = poll?.myVotes ?? [];
|
|
229
|
+
const hasVoted = myVotes.length > 0;
|
|
230
|
+
const [selection, setSelection] = useState<string[]>(myVotes);
|
|
231
|
+
// Changing a vote reopens the options; a fresh answer from the server closes them.
|
|
232
|
+
const [changing, setChanging] = useState(false);
|
|
233
|
+
const [error, setError] = useState<CommunityPollVoteError | null>(null);
|
|
234
|
+
const myVotesKey = myVotes.join(",");
|
|
235
|
+
|
|
236
|
+
useEffect(() => {
|
|
237
|
+
setSelection(poll?.myVotes ?? []);
|
|
238
|
+
// Re-sync only when the server's answer changes, not on every render.
|
|
239
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
240
|
+
}, [myVotesKey, poll?.id]);
|
|
241
|
+
|
|
242
|
+
const closed = !!poll?.closed;
|
|
243
|
+
const canVote = !!poll && !closed && !opts.disabled;
|
|
244
|
+
/** Options are pickable: before a first vote, or while changing one. */
|
|
245
|
+
const choosing = canVote && (!hasVoted || changing);
|
|
246
|
+
|
|
247
|
+
const send = async (optionIds: string[]) => {
|
|
248
|
+
if (!poll) return;
|
|
249
|
+
setError(null);
|
|
250
|
+
try {
|
|
251
|
+
await vote.mutateAsync({ postId: post.id, spaceId: post.spaceId, optionIds });
|
|
252
|
+
setChanging(false);
|
|
253
|
+
} catch (e) {
|
|
254
|
+
setError(pollVoteErrorCode(e));
|
|
255
|
+
}
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
return {
|
|
259
|
+
poll,
|
|
260
|
+
closed,
|
|
261
|
+
hasVoted,
|
|
262
|
+
canVote,
|
|
263
|
+
choosing,
|
|
264
|
+
changing,
|
|
265
|
+
selection,
|
|
266
|
+
isSelected: (optionId: string) => selection.includes(optionId),
|
|
267
|
+
toggle: (optionId: string) => {
|
|
268
|
+
if (!choosing || !poll) return;
|
|
269
|
+
setSelection((prev) =>
|
|
270
|
+
poll.multipleChoice
|
|
271
|
+
? prev.includes(optionId)
|
|
272
|
+
? prev.filter((id) => id !== optionId)
|
|
273
|
+
: [...prev, optionId]
|
|
274
|
+
: [optionId],
|
|
275
|
+
);
|
|
276
|
+
},
|
|
277
|
+
/** Something is picked, and it differs from the answer already on record. */
|
|
278
|
+
canSubmit: choosing && selection.length > 0 && !sameAnswer(selection, myVotes) && !vote.isPending,
|
|
279
|
+
submit: () => send(selection),
|
|
280
|
+
startChange: () => {
|
|
281
|
+
if (!canVote) return;
|
|
282
|
+
setSelection(myVotes);
|
|
283
|
+
setChanging(true);
|
|
284
|
+
setError(null);
|
|
285
|
+
},
|
|
286
|
+
cancelChange: () => {
|
|
287
|
+
setSelection(myVotes);
|
|
288
|
+
setChanging(false);
|
|
289
|
+
setError(null);
|
|
290
|
+
},
|
|
291
|
+
/** Take the vote back: an empty answer. */
|
|
292
|
+
withdraw: () => send([]),
|
|
293
|
+
pending: vote.isPending,
|
|
294
|
+
error,
|
|
295
|
+
hiddenReason: poll ? pollHiddenReason(poll) : null,
|
|
296
|
+
};
|
|
297
|
+
}
|