@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,52 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
3
|
+
import { renderHook, waitFor } from "@testing-library/react";
|
|
4
|
+
import { resolveAppHomeHref, useAppHomeHref, useSiteApp } from "../../../data/queries/useSiteApp";
|
|
5
|
+
import { calls, get, installAdapter, restoreAdapter, wrapper } from "./communityHarness";
|
|
6
|
+
import { siteApp } from "./siteAppHarness";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The site app's public settings, read for the host the visitor is on, and the
|
|
10
|
+
* Home rule built on them.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
afterEach(restoreAdapter);
|
|
14
|
+
|
|
15
|
+
describe("useSiteApp", () => {
|
|
16
|
+
it("asks for the settings of the host the visitor is on", async () => {
|
|
17
|
+
installAdapter([get("/public/websites/app", () => siteApp())]);
|
|
18
|
+
const { result } = renderHook(() => useSiteApp(), { wrapper: wrapper({ community: null }) });
|
|
19
|
+
await waitFor(() => expect(result.current.data).toBeDefined());
|
|
20
|
+
const sent = calls("get", "/public/websites/app");
|
|
21
|
+
expect(sent).toHaveLength(1);
|
|
22
|
+
expect(sent[0]!.params).toEqual({ host: window.location.host });
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe("useAppHomeHref", () => {
|
|
27
|
+
it("is the creator's start page when they chose one", async () => {
|
|
28
|
+
installAdapter([get("/public/websites/app", () => siteApp({ startUrl: "/members" }))]);
|
|
29
|
+
const { result } = renderHook(() => useAppHomeHref(), { wrapper: wrapper({ community: null }) });
|
|
30
|
+
await waitFor(() => expect(result.current).toBe("/members"));
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("falls back to /i/app before the settings arrive and when the host is unknown", async () => {
|
|
34
|
+
installAdapter([
|
|
35
|
+
get("/public/websites/app", () => {
|
|
36
|
+
throw { status: 404 };
|
|
37
|
+
}),
|
|
38
|
+
]);
|
|
39
|
+
const { result } = renderHook(() => useAppHomeHref(), { wrapper: wrapper({ community: null }) });
|
|
40
|
+
expect(result.current).toBe("/i/app");
|
|
41
|
+
await waitFor(() => expect(calls("get", "/public/websites/app")).toHaveLength(1));
|
|
42
|
+
expect(result.current).toBe("/i/app");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("never sends Home off-site", () => {
|
|
46
|
+
expect(resolveAppHomeHref({ startUrl: "//evil.example" })).toBe("/i/app");
|
|
47
|
+
expect(resolveAppHomeHref({ startUrl: "https://evil.example" })).toBe("/i/app");
|
|
48
|
+
expect(resolveAppHomeHref({ startUrl: "" })).toBe("/i/app");
|
|
49
|
+
expect(resolveAppHomeHref(null)).toBe("/i/app");
|
|
50
|
+
expect(resolveAppHomeHref({ startUrl: "/start" })).toBe("/start");
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
3
|
+
import { act, renderHook, waitFor } from "@testing-library/react";
|
|
4
|
+
import { useWikiMediaImage, wikiImageSource, wikiMediaImageStatus } from "../communities/useCommunityWiki";
|
|
5
|
+
import { wikiMediaRefreshMs } from "../../../data/queries/useCommunityWiki";
|
|
6
|
+
import { PROFILE_ID, account, calls, get, installAdapter, restoreAdapter, wrapper } from "./communityHarness";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Private wiki images. A page body names one as `kbmedia:<id>`, and the site
|
|
10
|
+
* exchanges it for a signed address that lives ten minutes, asked for with the
|
|
11
|
+
* page it sits on. These pin which `src` values render at all, what is sent,
|
|
12
|
+
* the refresh before expiry, the single re-request after a failed load, and
|
|
13
|
+
* the quiet unavailable state.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
afterEach(restoreAdapter);
|
|
17
|
+
|
|
18
|
+
const MEDIA = "/public/communities/fan-club/wiki/start-here/media/m-1";
|
|
19
|
+
|
|
20
|
+
describe("wikiImageSource", () => {
|
|
21
|
+
it("keeps plain http(s) images and site paths as they are", () => {
|
|
22
|
+
expect(wikiImageSource("https://cdn.example.com/a.png")).toEqual({
|
|
23
|
+
kind: "url",
|
|
24
|
+
url: "https://cdn.example.com/a.png",
|
|
25
|
+
});
|
|
26
|
+
expect(wikiImageSource("http://example.com/a.png")).toEqual({ kind: "url", url: "http://example.com/a.png" });
|
|
27
|
+
expect(wikiImageSource("/uploads/a.png")).toEqual({ kind: "url", url: "/uploads/a.png" });
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("reads a private reference as its media id", () => {
|
|
31
|
+
expect(wikiImageSource("kbmedia:abc-123_X")).toEqual({ kind: "private", mediaId: "abc-123_X" });
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("never renders anything else", () => {
|
|
35
|
+
for (const src of [
|
|
36
|
+
"javascript:alert(1)",
|
|
37
|
+
"data:image/png;base64,AAAA",
|
|
38
|
+
"//evil.example.com/a.png",
|
|
39
|
+
"kbmedia:",
|
|
40
|
+
"kbmedia:../../etc",
|
|
41
|
+
"kb-private:abc",
|
|
42
|
+
"",
|
|
43
|
+
42,
|
|
44
|
+
null,
|
|
45
|
+
]) {
|
|
46
|
+
expect(wikiImageSource(src), String(src)).toBeNull();
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe("wikiMediaImageStatus", () => {
|
|
52
|
+
const base = {
|
|
53
|
+
isLoading: false,
|
|
54
|
+
isError: false,
|
|
55
|
+
url: "u1",
|
|
56
|
+
failedUrls: new Set<string>(),
|
|
57
|
+
retriedAfterFailure: false,
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
it("is loading until an address arrives, then ready", () => {
|
|
61
|
+
expect(wikiMediaImageStatus({ ...base, url: null, isLoading: true })).toBe("loading");
|
|
62
|
+
expect(wikiMediaImageStatus(base)).toBe("ready");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("is unavailable on a refused or failed request", () => {
|
|
66
|
+
expect(wikiMediaImageStatus({ ...base, url: null, isError: true })).toBe("unavailable");
|
|
67
|
+
expect(wikiMediaImageStatus({ ...base, url: null })).toBe("unavailable");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("waits for a fresh address after the first failed load, and gives up after the second", () => {
|
|
71
|
+
const failed = new Set(["u1"]);
|
|
72
|
+
expect(wikiMediaImageStatus({ ...base, failedUrls: failed })).toBe("loading");
|
|
73
|
+
expect(wikiMediaImageStatus({ ...base, failedUrls: failed, retriedAfterFailure: true })).toBe("unavailable");
|
|
74
|
+
expect(wikiMediaImageStatus({ ...base, url: "u2", failedUrls: failed, retriedAfterFailure: true })).toBe("ready");
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe("wikiMediaRefreshMs", () => {
|
|
79
|
+
it("refreshes a minute before expiry, never under 30 seconds", () => {
|
|
80
|
+
expect(wikiMediaRefreshMs(600)).toBe(540_000);
|
|
81
|
+
expect(wikiMediaRefreshMs(60)).toBe(30_000);
|
|
82
|
+
expect(wikiMediaRefreshMs(undefined)).toBe(540_000);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe("useWikiMediaImage", () => {
|
|
87
|
+
it("asks for the signed address with the page it sits on", async () => {
|
|
88
|
+
installAdapter([get(MEDIA, () => ({ url: "https://signed.example.com/1", expiresInSeconds: 600 }))]);
|
|
89
|
+
const { result } = renderHook(() => useWikiMediaImage("start-here", "m-1"), {
|
|
90
|
+
wrapper: wrapper({ user: account() }),
|
|
91
|
+
});
|
|
92
|
+
expect(result.current.status).toBe("loading");
|
|
93
|
+
await waitFor(() => expect(result.current.status).toBe("ready"));
|
|
94
|
+
expect(result.current.url).toBe("https://signed.example.com/1");
|
|
95
|
+
expect(calls("get", MEDIA)[0].params).toEqual({ profileId: PROFILE_ID });
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("is unavailable when the server refuses, without retrying", async () => {
|
|
99
|
+
installAdapter([
|
|
100
|
+
get(MEDIA, () => {
|
|
101
|
+
throw { status: 404, message: "Not found" };
|
|
102
|
+
}),
|
|
103
|
+
]);
|
|
104
|
+
const { result } = renderHook(() => useWikiMediaImage("start-here", "m-1"), { wrapper: wrapper() });
|
|
105
|
+
await waitFor(() => expect(result.current.status).toBe("unavailable"));
|
|
106
|
+
expect(calls("get", MEDIA)).toHaveLength(1);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("replaces an address the browser could not load once, then gives up", async () => {
|
|
110
|
+
let n = 0;
|
|
111
|
+
installAdapter([get(MEDIA, () => ({ url: `https://signed.example.com/${++n}`, expiresInSeconds: 600 }))]);
|
|
112
|
+
const { result } = renderHook(() => useWikiMediaImage("start-here", "m-1"), {
|
|
113
|
+
wrapper: wrapper({ user: account() }),
|
|
114
|
+
});
|
|
115
|
+
await waitFor(() => expect(result.current.url).toBe("https://signed.example.com/1"));
|
|
116
|
+
|
|
117
|
+
act(() => result.current.onImageError());
|
|
118
|
+
await waitFor(() => expect(result.current.url).toBe("https://signed.example.com/2"));
|
|
119
|
+
expect(result.current.status).toBe("ready");
|
|
120
|
+
|
|
121
|
+
act(() => result.current.onImageError());
|
|
122
|
+
await waitFor(() => expect(result.current.status).toBe("unavailable"));
|
|
123
|
+
expect(calls("get", MEDIA)).toHaveLength(2);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("asks for nothing without a media id", () => {
|
|
127
|
+
installAdapter([]);
|
|
128
|
+
const { result } = renderHook(() => useWikiMediaImage("start-here", undefined), { wrapper: wrapper() });
|
|
129
|
+
expect(result.current.status).toBe("unavailable");
|
|
130
|
+
expect(calls("get", /\/media\//)).toHaveLength(0);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { useContext, useState } from "react";
|
|
2
|
+
import { PublicAuthContext } from "../../../contexts/PublicAuthContext";
|
|
3
|
+
import {
|
|
4
|
+
useCompleteEmailSignUp,
|
|
5
|
+
useStartEmailSignIn,
|
|
6
|
+
useVerifyEmailSignIn,
|
|
7
|
+
type EmailSignInSession,
|
|
8
|
+
} from "../../../data/queries/useEmailSignIn";
|
|
9
|
+
|
|
10
|
+
const emailRe = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
11
|
+
const msg = (e: unknown) => (e as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Where the email-first sign-in is:
|
|
15
|
+
*
|
|
16
|
+
* email the address, then Continue (emails a code).
|
|
17
|
+
* code the 6-digit code. Right for an address with an account: signed in.
|
|
18
|
+
* profile right, but the address has no account here yet: name and terms.
|
|
19
|
+
*/
|
|
20
|
+
export type EmailCodeSignInStep = "email" | "code" | "profile";
|
|
21
|
+
|
|
22
|
+
export interface UseEmailCodeSignInOptions {
|
|
23
|
+
/** Called once signed in, by the code (`existing`) or after the name step (`new`). */
|
|
24
|
+
onSignedIn?: (result: { kind: "existing" | "new" }) => void;
|
|
25
|
+
/** Require the terms checkbox on the name step (default true). The server requires it regardless. */
|
|
26
|
+
requireAcceptance?: boolean;
|
|
27
|
+
/** Start with this email filled in. */
|
|
28
|
+
defaultEmail?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Headless email-first sign-in: one email field, one code, and a name only for
|
|
33
|
+
* someone new. Nobody chooses between signing up and signing in.
|
|
34
|
+
*
|
|
35
|
+
* email -> code -> (signed in) | profile -> (signed in)
|
|
36
|
+
*
|
|
37
|
+
* The session is stored through `PublicAuthContext.adoptSession`, the same
|
|
38
|
+
* storage every other sign-in uses. Must sit inside a `PublicAuthProvider`.
|
|
39
|
+
*/
|
|
40
|
+
export function useEmailCodeSignIn(opts: UseEmailCodeSignInOptions = {}) {
|
|
41
|
+
const auth = useContext(PublicAuthContext);
|
|
42
|
+
const start = useStartEmailSignIn();
|
|
43
|
+
const verify = useVerifyEmailSignIn();
|
|
44
|
+
const complete = useCompleteEmailSignUp();
|
|
45
|
+
|
|
46
|
+
const [step, setStep] = useState<EmailCodeSignInStep>("email");
|
|
47
|
+
const [email, setEmail] = useState(opts.defaultEmail ?? "");
|
|
48
|
+
const [code, setCode] = useState("");
|
|
49
|
+
const [ticket, setTicket] = useState<string | null>(null);
|
|
50
|
+
const [firstName, setFirstName] = useState("");
|
|
51
|
+
const [lastName, setLastName] = useState("");
|
|
52
|
+
const [acceptedTerms, setAcceptedTerms] = useState(false);
|
|
53
|
+
const [error, setError] = useState<string | null>(null);
|
|
54
|
+
const [resent, setResent] = useState(false);
|
|
55
|
+
|
|
56
|
+
const requireAcceptance = opts.requireAcceptance ?? true;
|
|
57
|
+
const trimmed = email.trim();
|
|
58
|
+
|
|
59
|
+
const signIn = (session: EmailSignInSession, kind: "existing" | "new") => {
|
|
60
|
+
auth?.adoptSession({ token: session.token, refreshToken: session.refreshToken, account: session.account });
|
|
61
|
+
opts.onSignedIn?.({ kind });
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/** Step 1: email a code. */
|
|
65
|
+
const submitEmail = async () => {
|
|
66
|
+
setError(null);
|
|
67
|
+
if (!emailRe.test(trimmed)) {
|
|
68
|
+
setError("Please enter a valid email.");
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
try {
|
|
72
|
+
await start.mutateAsync({ email: trimmed });
|
|
73
|
+
setCode("");
|
|
74
|
+
setResent(false);
|
|
75
|
+
setStep("code");
|
|
76
|
+
return true;
|
|
77
|
+
} catch (e) {
|
|
78
|
+
setError(msg(e) || "Could not send the code.");
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/** Step 2: redeem the code. Signs in an existing account, or opens the name step. */
|
|
84
|
+
const submitCode = async () => {
|
|
85
|
+
setError(null);
|
|
86
|
+
const value = code.trim();
|
|
87
|
+
if (!/^\d{6}$/.test(value)) {
|
|
88
|
+
setError("Enter the 6-digit code we emailed you.");
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
try {
|
|
92
|
+
const answer = await verify.mutateAsync({ email: trimmed, code: value });
|
|
93
|
+
if (answer.status === "needs_profile") {
|
|
94
|
+
setTicket(answer.ticket);
|
|
95
|
+
setStep("profile");
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
signIn(answer, "existing");
|
|
99
|
+
return true;
|
|
100
|
+
} catch (e) {
|
|
101
|
+
setError(msg(e) || "That code did not work.");
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/** Step 3, for someone new: create the account and sign in. */
|
|
107
|
+
const submitProfile = async () => {
|
|
108
|
+
setError(null);
|
|
109
|
+
const first = firstName.trim();
|
|
110
|
+
const last = lastName.trim();
|
|
111
|
+
if (!first || !last) {
|
|
112
|
+
setError("Please enter your name.");
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
if (first.length > 100 || last.length > 100) {
|
|
116
|
+
setError("Please use a shorter name.");
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
if (requireAcceptance && !acceptedTerms) {
|
|
120
|
+
setError("You must accept the Terms and Privacy Policy.");
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
if (!ticket) {
|
|
124
|
+
setStep("email");
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
try {
|
|
128
|
+
const session = await complete.mutateAsync({
|
|
129
|
+
email: trimmed,
|
|
130
|
+
ticket,
|
|
131
|
+
firstName: first,
|
|
132
|
+
lastName: last,
|
|
133
|
+
acceptedPrivacy: true,
|
|
134
|
+
});
|
|
135
|
+
signIn(session, "new");
|
|
136
|
+
return true;
|
|
137
|
+
} catch (e) {
|
|
138
|
+
setError(msg(e) || "Could not create your account.");
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
/** Email another code (throttled to one per 60 seconds server-side). */
|
|
144
|
+
const resendCode = async () => {
|
|
145
|
+
setError(null);
|
|
146
|
+
setResent(false);
|
|
147
|
+
try {
|
|
148
|
+
await start.mutateAsync({ email: trimmed });
|
|
149
|
+
setResent(true);
|
|
150
|
+
} catch (e) {
|
|
151
|
+
setError(msg(e) || "Could not resend the code.");
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
/** Back to the email field, to correct a mistyped address. */
|
|
156
|
+
const changeEmail = () => {
|
|
157
|
+
setStep("email");
|
|
158
|
+
setCode("");
|
|
159
|
+
setTicket(null);
|
|
160
|
+
setResent(false);
|
|
161
|
+
setError(null);
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
step,
|
|
166
|
+
email,
|
|
167
|
+
setEmail,
|
|
168
|
+
code,
|
|
169
|
+
setCode,
|
|
170
|
+
firstName,
|
|
171
|
+
setFirstName,
|
|
172
|
+
lastName,
|
|
173
|
+
setLastName,
|
|
174
|
+
acceptedTerms,
|
|
175
|
+
setAcceptedTerms,
|
|
176
|
+
error,
|
|
177
|
+
/** True right after a resend succeeded, until the next action. */
|
|
178
|
+
resent,
|
|
179
|
+
isSubmitting: start.isPending || verify.isPending || complete.isPending,
|
|
180
|
+
submitEmail,
|
|
181
|
+
submitCode,
|
|
182
|
+
submitProfile,
|
|
183
|
+
resendCode,
|
|
184
|
+
changeEmail,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
@@ -11,8 +11,27 @@ export interface UseSignupFormOptions {
|
|
|
11
11
|
couponCode?: string;
|
|
12
12
|
/** Require the terms/privacy checkbox before submit (default true). */
|
|
13
13
|
requireAcceptance?: boolean;
|
|
14
|
+
/**
|
|
15
|
+
* Start with this email filled in. When omitted, an address handed over by a
|
|
16
|
+
* client-portal invite in this tab (`rememberSignupEmail`) is used once.
|
|
17
|
+
*/
|
|
18
|
+
defaultEmail?: string;
|
|
14
19
|
}
|
|
15
20
|
|
|
21
|
+
const SIGNUP_EMAIL_PREFILL_KEY = "forge:signup-email";
|
|
22
|
+
|
|
23
|
+
/** The invite page's handed-over email, read and cleared in one go. */
|
|
24
|
+
const takePrefilledEmail = (): string => {
|
|
25
|
+
if (typeof window === "undefined") return "";
|
|
26
|
+
try {
|
|
27
|
+
const value = window.sessionStorage.getItem(SIGNUP_EMAIL_PREFILL_KEY) ?? "";
|
|
28
|
+
if (value) window.sessionStorage.removeItem(SIGNUP_EMAIL_PREFILL_KEY);
|
|
29
|
+
return value;
|
|
30
|
+
} catch {
|
|
31
|
+
return "";
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
|
|
16
35
|
/**
|
|
17
36
|
* Headless account signup. Bring your own UI (or use the styled <SignupForm>).
|
|
18
37
|
*
|
|
@@ -27,7 +46,7 @@ export function useSignupForm(opts: UseSignupFormOptions = {}) {
|
|
|
27
46
|
|
|
28
47
|
const [firstName, setFirstName] = useState("");
|
|
29
48
|
const [lastName, setLastName] = useState("");
|
|
30
|
-
const [email, setEmail] = useState(
|
|
49
|
+
const [email, setEmail] = useState(() => opts.defaultEmail ?? takePrefilledEmail());
|
|
31
50
|
const [password, setPassword] = useState("");
|
|
32
51
|
const [acceptedTerms, setAcceptedTerms] = useState(false);
|
|
33
52
|
const [error, setError] = useState<string | null>(null);
|
|
@@ -26,8 +26,11 @@ const nextId = () => `att-${Date.now()}-${counter++}`;
|
|
|
26
26
|
* upload them to R2 via presigned URLs, and hand back `ChatAttachment[]` to send.
|
|
27
27
|
* Mirrors the admin `useUploadFiles` flow but Forge-native (uses the tenant client).
|
|
28
28
|
*/
|
|
29
|
-
export function useChatAttachmentUpload() {
|
|
29
|
+
export function useChatAttachmentUpload(options: { presignPath?: string } = {}) {
|
|
30
30
|
const { client, profileId } = useForge();
|
|
31
|
+
// Community chat signs uploads at its own member-scoped endpoint. A surface
|
|
32
|
+
// with its own access rule (the client portal's Messages) names its own.
|
|
33
|
+
const presignPath = options.presignPath ?? "/public/chat/attachments/presigned-url";
|
|
31
34
|
const [pending, setPending] = useState<PendingAttachment[]>([]);
|
|
32
35
|
const [error, setError] = useState<string | null>(null);
|
|
33
36
|
const pendingRef = useRef<PendingAttachment[]>([]);
|
|
@@ -89,10 +92,10 @@ export function useChatAttachmentUpload() {
|
|
|
89
92
|
// Member-scoped presigned endpoint. The admin `/uploads/presigned-url`
|
|
90
93
|
// route requires profile write permissions, which website members
|
|
91
94
|
// don't have — using it made file-only messages fail silently.
|
|
92
|
-
const { data } = await client.post<{ presignedUrl: string; remoteUrl: string }>(
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
);
|
|
95
|
+
const { data } = await client.post<{ presignedUrl: string; remoteUrl: string }>(presignPath, {
|
|
96
|
+
fileName: item.file.name,
|
|
97
|
+
profileId,
|
|
98
|
+
});
|
|
96
99
|
await axios.put(data.presignedUrl, item.file, {
|
|
97
100
|
headers: { "Content-Type": item.file.type || "application/octet-stream" },
|
|
98
101
|
onUploadProgress: (e) => {
|
|
@@ -116,7 +119,7 @@ export function useChatAttachmentUpload() {
|
|
|
116
119
|
}
|
|
117
120
|
|
|
118
121
|
return uploaded;
|
|
119
|
-
}, [client, profileId, patch]);
|
|
122
|
+
}, [client, profileId, patch, presignPath]);
|
|
120
123
|
|
|
121
124
|
return {
|
|
122
125
|
pending,
|
|
@@ -15,6 +15,8 @@ import {
|
|
|
15
15
|
type ChatChannel,
|
|
16
16
|
type ChatMessage,
|
|
17
17
|
} from "../../../data/queries/useChat";
|
|
18
|
+
import { useOpenCommunityAgentDm, COMMUNITY_AGENT_DM_KEY_PREFIX } from "../../../data/queries/useCommunityAgent";
|
|
19
|
+
import { useChatAgentThinking } from "../community/useCommunityAgent";
|
|
18
20
|
|
|
19
21
|
/**
|
|
20
22
|
* Headless community chat room: channel list + unread counts, paginated messages
|
|
@@ -23,7 +25,7 @@ import {
|
|
|
23
25
|
* and same-tier member DMs.
|
|
24
26
|
*
|
|
25
27
|
* Composes the `useChat*` data hooks and `useChatSocket`. This is the single
|
|
26
|
-
* primitive a creator needs to rebuild the chat page
|
|
28
|
+
* primitive a creator needs to rebuild the chat page. Render however you like.
|
|
27
29
|
*/
|
|
28
30
|
export function useChatRoom() {
|
|
29
31
|
const { user } = usePublicAuth();
|
|
@@ -33,6 +35,7 @@ export function useChatRoom() {
|
|
|
33
35
|
const { data: unreadCounts = [] } = useChatUnreadCounts();
|
|
34
36
|
const { data: memberDirectory = [] } = useChatMemberDirectory();
|
|
35
37
|
const createMemberDm = useCreateMemberDm();
|
|
38
|
+
const openAgentDm = useOpenCommunityAgentDm();
|
|
36
39
|
const markRead = useMarkChatRead();
|
|
37
40
|
|
|
38
41
|
const [selectedChannel, setSelectedChannel] = useState<ChatChannel | null>(null);
|
|
@@ -138,6 +141,9 @@ export function useChatRoom() {
|
|
|
138
141
|
};
|
|
139
142
|
}, [socket, queryClient, user?.id]);
|
|
140
143
|
|
|
144
|
+
// The community AI writing an answer (in a thread, or in its DM).
|
|
145
|
+
const agentThinking = useChatAgentThinking(socket);
|
|
146
|
+
|
|
141
147
|
// Join/leave the selected channel room.
|
|
142
148
|
useEffect(() => {
|
|
143
149
|
if (!selectedChannel?.id || !socket?.connected) return;
|
|
@@ -194,6 +200,28 @@ export function useChatRoom() {
|
|
|
194
200
|
[createMemberDm],
|
|
195
201
|
);
|
|
196
202
|
|
|
203
|
+
/**
|
|
204
|
+
* Open the member's DM with the community AI and select it. The DM is an
|
|
205
|
+
* ordinary chat channel; until the refreshed channel list carries it, a
|
|
206
|
+
* stand-in with the agent's name is selected so the panel opens at once.
|
|
207
|
+
*/
|
|
208
|
+
const startAgentDm = useCallback(async () => {
|
|
209
|
+
const result = await openAgentDm.mutateAsync();
|
|
210
|
+
const listed = channels.find((c) => c.id === result.channelId);
|
|
211
|
+
const channel: ChatChannel = listed ?? {
|
|
212
|
+
id: result.channelId,
|
|
213
|
+
name: result.agent.enabled ? result.agent.name : null,
|
|
214
|
+
type: "dm",
|
|
215
|
+
description: null,
|
|
216
|
+
otherParticipant: null,
|
|
217
|
+
dmKey: `${COMMUNITY_AGENT_DM_KEY_PREFIX}${result.agent.enabled ? result.agent.id : ""}`,
|
|
218
|
+
};
|
|
219
|
+
setSelectedChannel(channel);
|
|
220
|
+
setActiveThreadParent(null);
|
|
221
|
+
setTypingUsers([]);
|
|
222
|
+
return channel;
|
|
223
|
+
}, [openAgentDm, channels]);
|
|
224
|
+
|
|
197
225
|
return {
|
|
198
226
|
channels,
|
|
199
227
|
channelsLoading,
|
|
@@ -219,5 +247,11 @@ export function useChatRoom() {
|
|
|
219
247
|
memberDirectory,
|
|
220
248
|
startMemberDm,
|
|
221
249
|
isStartingDm: createMemberDm.isPending,
|
|
250
|
+
// The community AI
|
|
251
|
+
startAgentDm,
|
|
252
|
+
isStartingAgentDm: openAgentDm.isPending,
|
|
253
|
+
agentDmError: openAgentDm.error,
|
|
254
|
+
/** Whether the community AI is answering in this channel (null thread = the channel itself, as in its DM). */
|
|
255
|
+
isAgentThinking: agentThinking.isThinking,
|
|
222
256
|
};
|
|
223
257
|
}
|
|
@@ -53,3 +53,44 @@ export { useCommunityHome, type UseCommunityHomeOptions } from "./useCommunityHo
|
|
|
53
53
|
export { useMyMemberships, toMyMembershipEntry, type MyMembershipEntry } from "./useMyMemberships";
|
|
54
54
|
export { CommunityGate, communityGateAllows, type CommunityGateProps, type CommunityGateState } from "./CommunityGate";
|
|
55
55
|
export { communitiesListPath, communityAboutPath, communityHomePath } from "./paths";
|
|
56
|
+
export {
|
|
57
|
+
useCommunityWiki,
|
|
58
|
+
useCommunityWikiPage,
|
|
59
|
+
shapeWikiList,
|
|
60
|
+
defaultWikiPageSlug,
|
|
61
|
+
parseWikiContent,
|
|
62
|
+
extractWikiToc,
|
|
63
|
+
wikiHeadingSlug,
|
|
64
|
+
lexicalNodeText,
|
|
65
|
+
wikiImageSource,
|
|
66
|
+
wikiMediaImageStatus,
|
|
67
|
+
wikiUnlockParts,
|
|
68
|
+
useWikiMediaImage,
|
|
69
|
+
type WikiImageSource,
|
|
70
|
+
type WikiMediaImageStatus,
|
|
71
|
+
WIKI_TOC_MIN_HEADINGS,
|
|
72
|
+
type CommunityWikiTocEntry,
|
|
73
|
+
type UseCommunityWikiOptions,
|
|
74
|
+
} from "./useCommunityWiki";
|
|
75
|
+
export {
|
|
76
|
+
useCommunityClassroom,
|
|
77
|
+
classroomProgressPercent,
|
|
78
|
+
classroomCourseState,
|
|
79
|
+
classroomUnlockParts,
|
|
80
|
+
classroomCourseHref,
|
|
81
|
+
type ClassroomCourseState,
|
|
82
|
+
} from "./useCommunityClassroom";
|
|
83
|
+
export {
|
|
84
|
+
useCommunityCalendar,
|
|
85
|
+
calendarDayKey,
|
|
86
|
+
calendarMonthStart,
|
|
87
|
+
calendarMonthWindow,
|
|
88
|
+
calendarItemIsOver,
|
|
89
|
+
groupCalendarItems,
|
|
90
|
+
rsvpErrorCode,
|
|
91
|
+
nextRsvp,
|
|
92
|
+
callJoinable,
|
|
93
|
+
type CommunityCalendarDay,
|
|
94
|
+
type CommunityRsvpErrorCode,
|
|
95
|
+
type UseCommunityCalendarOptions,
|
|
96
|
+
} from "./useCommunityCalendar";
|