@tribe-nest/forge 3.59.0 → 3.61.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -1
- package/src/_tests/testCommunity.tsx +28 -0
- package/src/contexts/CartContext.tsx +34 -1
- package/src/contexts/CommunityContext.tsx +192 -0
- package/src/contexts/PublicAuthContext.tsx +15 -3
- package/src/data/queries/_tests/metaSignals.spec.tsx +130 -0
- package/src/data/queries/useAccount.ts +33 -2
- package/src/data/queries/useAnalytics.ts +6 -6
- package/src/data/queries/useCallChat.ts +2 -2
- package/src/data/queries/useChat.ts +52 -40
- package/src/data/queries/useCheckouts.ts +2 -1
- package/src/data/queries/useCoachingAvailability.ts +10 -1
- package/src/data/queries/useCommunities.ts +345 -0
- package/src/data/queries/useCommunity.ts +450 -102
- package/src/data/queries/useCommunityAgent.ts +247 -0
- package/src/data/queries/useCommunityCalendar.ts +150 -0
- package/src/data/queries/useCommunityClassroom.ts +75 -0
- package/src/data/queries/useCommunityMembers.ts +108 -0
- package/src/data/queries/useCommunityModeration.ts +327 -0
- package/src/data/queries/useCommunityWiki.ts +137 -0
- package/src/data/queries/useCourses.ts +2 -0
- package/src/data/queries/useEmailSignIn.ts +79 -0
- package/src/data/queries/useEvents.ts +6 -1
- package/src/data/queries/useForms.ts +5 -1
- package/src/data/queries/useLeadMagnet.ts +6 -2
- package/src/data/queries/useMembership.ts +32 -7
- package/src/data/queries/useMembershipCalls.ts +116 -7
- package/src/data/queries/useMyBookings.ts +24 -1
- package/src/data/queries/useMyTickets.ts +24 -1
- package/src/data/queries/useNotifications.ts +6 -2
- package/src/data/queries/useOrders.ts +2 -1
- package/src/data/queries/usePWA.ts +111 -35
- package/src/data/queries/usePostCollections.ts +28 -16
- package/src/data/queries/usePosts.ts +16 -14
- package/src/data/queries/useSiteApp.ts +130 -0
- package/src/data/queries/useSubscriptions.ts +28 -9
- package/src/i18n/de.json +891 -2
- package/src/i18n/en.json +891 -2
- package/src/index.ts +36 -7
- package/src/runtime/pages.spec.ts +36 -0
- package/src/runtime/pages.ts +52 -27
- package/src/server/_tests/buildWebManifest.spec.ts +1 -1
- package/src/server/_tests/forgeServiceWorkerScript.spec.ts +4 -4
- package/src/types/models.ts +11 -0
- package/src/ui/analytics/ForgeAnalytics.tsx +64 -34
- package/src/ui/analytics/PageMetaPixel.tsx +23 -8
- package/src/ui/analytics/_tests/ForgeAnalytics.meta.spec.tsx +129 -0
- package/src/ui/analytics/_tests/metaCommerceEvents.spec.tsx +159 -0
- package/src/ui/analytics/useMetaEvents.ts +78 -0
- package/src/ui/format/_tests/membershipGate.spec.ts +6 -6
- package/src/ui/format/membershipGate.ts +19 -15
- package/src/ui/headless/_tests/communityAgent.spec.tsx +519 -0
- package/src/ui/headless/_tests/communityCalendar.spec.tsx +195 -0
- package/src/ui/headless/_tests/communityClassroom.spec.tsx +120 -0
- package/src/ui/headless/_tests/communityGate.spec.tsx +80 -0
- package/src/ui/headless/_tests/communityHarness.tsx +212 -0
- package/src/ui/headless/_tests/communityMembers.spec.tsx +316 -0
- package/src/ui/headless/_tests/communityPoll.spec.tsx +389 -0
- package/src/ui/headless/_tests/communityProvider.spec.tsx +151 -0
- package/src/ui/headless/_tests/memberHomeBar.spec.ts +84 -0
- package/src/ui/headless/_tests/siteAppBar.spec.ts +83 -0
- package/src/ui/headless/_tests/siteAppHarness.ts +19 -0
- package/src/ui/headless/_tests/siteAppHome.spec.ts +226 -0
- package/src/ui/headless/_tests/siteAppPrompts.spec.ts +185 -0
- package/src/ui/headless/_tests/useCommunityInvite.spec.tsx +104 -0
- package/src/ui/headless/_tests/useCommunityJoin.spec.tsx +596 -0
- package/src/ui/headless/_tests/useCommunityMembership.spec.tsx +179 -0
- package/src/ui/headless/_tests/useCommunityModeration.spec.tsx +420 -0
- package/src/ui/headless/_tests/useCommunityReads.spec.tsx +295 -0
- package/src/ui/headless/_tests/useCommunityUpgrade.spec.tsx +157 -0
- package/src/ui/headless/_tests/useCommunityWiki.spec.tsx +224 -0
- package/src/ui/headless/_tests/useSiteApp.spec.tsx +52 -0
- package/src/ui/headless/_tests/wikiMediaImage.spec.tsx +132 -0
- package/src/ui/headless/auth/useEmailCodeSignIn.ts +186 -0
- package/src/ui/headless/auth/useSignupForm.ts +30 -3
- package/src/ui/headless/chat/useChatAttachmentUpload.ts +9 -6
- package/src/ui/headless/chat/useChatRoom.ts +35 -1
- package/src/ui/headless/communities/CommunityGate.tsx +67 -0
- package/src/ui/headless/communities/index.ts +96 -0
- package/src/ui/headless/communities/paths.ts +36 -0
- package/src/ui/headless/communities/useCommunity.ts +140 -0
- package/src/ui/headless/communities/useCommunityAbout.ts +71 -0
- package/src/ui/headless/communities/useCommunityCalendar.ts +156 -0
- package/src/ui/headless/communities/useCommunityClassroom.ts +84 -0
- package/src/ui/headless/communities/useCommunityHome.ts +91 -0
- package/src/ui/headless/communities/useCommunityInvite.ts +116 -0
- package/src/ui/headless/communities/useCommunityJoin.ts +494 -0
- package/src/ui/headless/communities/useCommunityMembership.ts +182 -0
- package/src/ui/headless/communities/useCommunityUpgrade.ts +213 -0
- package/src/ui/headless/communities/useCommunityWiki.ts +266 -0
- package/src/ui/headless/communities/useMyMemberships.ts +59 -0
- package/src/ui/headless/community/communityPoll.ts +297 -0
- package/src/ui/headless/community/countries.ts +123 -0
- package/src/ui/headless/community/index.ts +81 -1
- package/src/ui/headless/community/linkPreview.ts +71 -0
- package/src/ui/headless/community/richLexical.ts +27 -2
- package/src/ui/headless/community/useCommunityAgent.ts +285 -0
- package/src/ui/headless/community/useCommunityAttachmentUpload.ts +7 -3
- package/src/ui/headless/community/useCommunityMembers.ts +175 -0
- package/src/ui/headless/community/useCommunityModeration.ts +300 -0
- package/src/ui/headless/community/useCommunitySpaceFeed.ts +22 -4
- package/src/ui/headless/community/useCommunityThread.ts +16 -3
- package/src/ui/headless/community/useMentionComposer.ts +17 -9
- package/src/ui/headless/community/useRichMentionComposer.ts +21 -5
- package/src/ui/headless/downloads/index.ts +8 -0
- package/src/ui/headless/downloads/useDownloadPage.ts +134 -0
- package/src/ui/headless/index.ts +21 -4
- package/src/ui/headless/memberHome/index.ts +11 -1
- package/src/ui/headless/memberHome/memberHomeBar.ts +91 -0
- package/src/ui/headless/memberHome/useMemberHomeNav.ts +84 -44
- package/src/ui/headless/membership/_tests/membershipCheckoutRefetch.spec.tsx +8 -0
- package/src/ui/headless/membership/_tests/useMembershipCheckoutApplication.spec.tsx +5 -4
- package/src/ui/headless/membership/_tests/useMembershipCheckoutQuestionnaire.spec.tsx +1 -1
- package/src/ui/headless/membership/_tests/useMembershipCheckoutTrial.spec.tsx +1 -1
- package/src/ui/headless/membership/useMembershipCheckout.ts +89 -22
- package/src/ui/headless/membership/useMembershipGateNotice.ts +25 -7
- package/src/ui/headless/portal/index.ts +57 -0
- package/src/ui/headless/portal/usePortal.ts +274 -0
- package/src/ui/headless/portal/usePortalFiles.ts +68 -0
- package/src/ui/headless/portal/usePortalMessages.ts +189 -0
- package/src/ui/headless/portal/usePortalPreview.ts +142 -0
- package/src/ui/headless/posts/upsell.ts +54 -8
- package/src/ui/headless/posts/useMemberPostsFeed.ts +6 -5
- package/src/ui/headless/posts/usePostCollectionDetail.ts +7 -4
- package/src/ui/headless/posts/usePostCollectionsList.ts +6 -5
- package/src/ui/headless/posts/usePostItem.ts +6 -5
- package/src/ui/headless/siteApp/appBar.ts +102 -0
- package/src/ui/headless/siteApp/appHome.ts +344 -0
- package/src/ui/headless/siteApp/appPrompts.ts +229 -0
- package/src/ui/headless/siteApp/index.ts +58 -0
- package/src/ui/headless/siteApp/useAppHome.ts +210 -0
- package/src/ui/headless/siteApp/useAppInstallRequest.ts +16 -0
- package/src/ui/headless/work/index.ts +2 -0
- package/src/ui/headless/work/useWorkPortal.ts +37 -26
- package/src/ui/index.ts +28 -12
- package/src/ui/styled/AccountDashboard.tsx +754 -602
- package/src/ui/styled/AiAgentWidget.tsx +13 -1
- package/src/ui/styled/ChatRoom.tsx +126 -16
- package/src/ui/styled/Checkout.tsx +8 -0
- package/src/ui/styled/CheckoutConfirmation.tsx +8 -0
- package/src/ui/styled/CoachingConfirmation.tsx +8 -0
- package/src/ui/styled/CoachingDetail.tsx +2 -0
- package/src/ui/styled/CourseConfirmation.tsx +5 -0
- package/src/ui/styled/CourseDetail.tsx +2 -0
- package/src/ui/styled/DownloadPage.tsx +263 -0
- package/src/ui/styled/EventConfirmation.tsx +10 -0
- package/src/ui/styled/EventDetail.tsx +10 -0
- package/src/ui/styled/FilmLibrary.tsx +22 -9
- package/src/ui/styled/MembershipCheckout.tsx +37 -17
- package/src/ui/styled/MembershipTierCallout.tsx +20 -1
- package/src/ui/styled/MembershipTiers.tsx +49 -11
- package/src/ui/styled/MusicLinkPage.tsx +5 -5
- package/src/ui/styled/Paywall.tsx +1 -1
- package/src/ui/styled/ProductDetail.tsx +12 -0
- package/src/ui/styled/PwaRegistration.tsx +5 -1
- package/src/ui/styled/SignupForm.tsx +18 -3
- package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +6 -6
- package/src/ui/styled/_tests/AccountDashboardMemberships.spec.tsx +151 -0
- package/src/ui/styled/_tests/AccountDashboardRentals.spec.tsx +1 -1
- package/src/ui/styled/_tests/AccountNotifications.spec.tsx +135 -0
- package/src/ui/styled/_tests/CourseCallsSection.spec.tsx +7 -1
- package/src/ui/styled/_tests/DownloadPage.spec.tsx +68 -0
- package/src/ui/styled/_tests/MembershipCheckout.spec.tsx +54 -11
- package/src/ui/styled/_tests/membershipTierCallout.spec.tsx +1 -1
- package/src/ui/styled/_tests/membershipTiersCuratedAccess.spec.tsx +1 -1
- package/src/ui/styled/app/AppBar.tsx +298 -0
- package/src/ui/styled/app/AppHome.tsx +1139 -0
- package/src/ui/styled/app/AppHomeMark.tsx +66 -0
- package/src/ui/styled/app/AppPrompts.tsx +464 -0
- package/src/ui/styled/app/_tests/AppBar.spec.tsx +96 -0
- package/src/ui/styled/app/_tests/AppHome.spec.tsx +209 -0
- package/src/ui/styled/app/_tests/AppPrompts.spec.tsx +232 -0
- package/src/ui/styled/app/index.ts +7 -0
- package/src/ui/styled/chat/ChatThreadPanel.tsx +29 -4
- package/src/ui/styled/chat/MessageBubble.tsx +43 -6
- package/src/ui/styled/communities/CommunitiesList.tsx +88 -0
- package/src/ui/styled/communities/CommunityAbout.tsx +145 -0
- package/src/ui/styled/communities/CommunityCalendar.tsx +484 -0
- package/src/ui/styled/communities/CommunityClassroom.tsx +302 -0
- package/src/ui/styled/communities/CommunityHome.tsx +298 -0
- package/src/ui/styled/communities/CommunityJoinSheet.tsx +544 -0
- package/src/ui/styled/communities/CommunityMembershipMenu.tsx +249 -0
- package/src/ui/styled/communities/CommunityPending.tsx +49 -0
- package/src/ui/styled/communities/CommunityPublicWiki.tsx +75 -0
- package/src/ui/styled/communities/CommunityUpgradeSheet.tsx +137 -0
- package/src/ui/styled/communities/CommunityWiki.tsx +289 -0
- package/src/ui/styled/communities/MyMembershipsList.tsx +115 -0
- package/src/ui/styled/communities/_tests/CommunityAppHome.spec.tsx +92 -0
- package/src/ui/styled/communities/_tests/CommunityClassroomCalendar.spec.tsx +453 -0
- package/src/ui/styled/communities/_tests/CommunityMobile.spec.tsx +399 -0
- package/src/ui/styled/communities/_tests/CommunityPages.spec.tsx +102 -0
- package/src/ui/styled/communities/_tests/CommunityWiki.spec.tsx +223 -0
- package/src/ui/styled/communities/index.ts +19 -0
- package/src/ui/styled/communities/shared.tsx +177 -0
- package/src/ui/styled/community/CommunityAgent.tsx +286 -0
- package/src/ui/styled/community/CommunityComposer.tsx +266 -15
- package/src/ui/styled/community/CommunityFeed.tsx +50 -1
- package/src/ui/styled/community/CommunityLinkPreview.tsx +159 -0
- package/src/ui/styled/community/CommunityMemberGlobe.tsx +171 -0
- package/src/ui/styled/community/CommunityMemberProfile.tsx +137 -4
- package/src/ui/styled/community/CommunityMembers.tsx +387 -0
- package/src/ui/styled/community/CommunityModeration.tsx +587 -0
- package/src/ui/styled/community/CommunityModerationState.tsx +127 -0
- package/src/ui/styled/community/CommunityModeratorMenu.tsx +317 -0
- package/src/ui/styled/community/CommunityNotifications.tsx +52 -6
- package/src/ui/styled/community/CommunityPollCard.tsx +250 -0
- package/src/ui/styled/community/CommunityPostCard.tsx +110 -34
- package/src/ui/styled/community/CommunityPostDetail.tsx +157 -71
- package/src/ui/styled/community/CommunityRichText.tsx +177 -32
- package/src/ui/styled/community/CommunitySpaces.tsx +49 -14
- package/src/ui/styled/community/_tests/CommunityAgent.spec.tsx +571 -0
- package/src/ui/styled/community/_tests/CommunityComposer.spec.tsx +4 -1
- package/src/ui/styled/community/_tests/CommunityLinkPreview.spec.tsx +167 -0
- package/src/ui/styled/community/_tests/CommunityMembers.spec.tsx +137 -0
- package/src/ui/styled/community/_tests/CommunityModeration.spec.tsx +291 -0
- package/src/ui/styled/community/_tests/CommunityPoll.spec.tsx +386 -0
- package/src/ui/styled/community/_tests/CommunityPostDetail.spec.tsx +4 -1
- package/src/ui/styled/community/index.ts +37 -2
- package/src/ui/styled/forge-utilities.css +253 -0
- package/src/ui/styled/members/CallRecordingActions.tsx +182 -0
- package/src/ui/styled/members/CallTranscriptViewer.tsx +424 -0
- package/src/ui/styled/members/MemberCallsCalendar.tsx +41 -54
- package/src/ui/styled/members/MemberHomeLayout.tsx +327 -29
- package/src/ui/styled/members/MemberPostCard.tsx +9 -5
- package/src/ui/styled/members/MemberPostsFeed.tsx +3 -2
- package/src/ui/styled/members/PostCollectionDetail.tsx +18 -10
- package/src/ui/styled/members/PostCollectionsGrid.tsx +24 -10
- package/src/ui/styled/members/_tests/MemberCallsCalendar.spec.tsx +167 -8
- package/src/ui/styled/members/callTranscriptText.ts +67 -0
- package/src/ui/styled/members/index.ts +8 -0
- package/src/ui/styled/portal/PortalAccountSection.tsx +61 -0
- package/src/ui/styled/portal/PortalActivity.tsx +167 -0
- package/src/ui/styled/portal/PortalBilling.tsx +197 -0
- package/src/ui/styled/portal/PortalFiles.tsx +279 -0
- package/src/ui/styled/portal/PortalHome.tsx +401 -0
- package/src/ui/styled/portal/PortalMessages.tsx +300 -0
- package/src/ui/styled/portal/PortalPreviewBanner.tsx +75 -0
- package/src/ui/styled/portal/PortalProjects.tsx +407 -0
- package/src/ui/styled/portal/PortalShell.tsx +397 -0
- package/src/ui/styled/portal/_tests/PortalAppHome.spec.tsx +90 -0
- package/src/ui/styled/portal/_tests/PortalFiles.spec.tsx +159 -0
- package/src/ui/styled/portal/_tests/PortalHome.spec.tsx +148 -0
- package/src/ui/styled/portal/_tests/PortalMessages.spec.tsx +168 -0
- package/src/ui/styled/portal/_tests/PortalPreviewBanner.spec.tsx +132 -0
- package/src/ui/styled/portal/_tests/portalNav.spec.ts +123 -0
- package/src/ui/styled/portal/index.ts +13 -0
- package/src/ui/styled/portal/portalNav.ts +260 -0
- package/src/ui/styled/portal/shared.tsx +206 -0
- package/src/ui/styled/work/WorkInviteAccept.tsx +228 -168
- package/src/ui/styled/work/WorkPortalProjects.tsx +68 -120
- package/src/ui/styled/work/WorkProjectDocuments.tsx +99 -128
- package/src/ui/styled/work/WorkProjectInvoices.tsx +91 -86
- package/src/ui/styled/work/WorkProjectReport.tsx +11 -17
- package/src/ui/styled/work/WorkReportBody.tsx +223 -248
- package/src/ui/styled/work/WorkTaskAttachments.tsx +23 -29
- package/src/ui/styled/work/WorkTaskComments.tsx +75 -102
- package/src/ui/styled/work/WorkTaskDetail.tsx +48 -59
- package/src/ui/styled/work/WorkTokenReport.tsx +8 -16
- package/src/ui/styled/work/index.ts +1 -1
- package/src/utils/_tests/metaEvents.spec.ts +264 -0
- package/src/utils/_tests/safeRedirect.spec.ts +2 -2
- package/src/utils/landing.ts +19 -6
- package/src/utils/metaEvents.ts +302 -0
- package/src/ui/headless/membership/MembershipGate.tsx +0 -44
- package/src/ui/styled/_tests/AccountDashboardCommunity.spec.tsx +0 -149
- package/src/ui/styled/_tests/AccountDashboardTrial.spec.tsx +0 -105
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
|
4
|
+
import type { ReactNode } from "react";
|
|
5
|
+
import { ForgeThemeProvider } from "../../../theme/ForgeThemeProvider";
|
|
6
|
+
import { AppHome } from "../AppHome";
|
|
7
|
+
import {
|
|
8
|
+
COMMUNITY,
|
|
9
|
+
OTHER_COMMUNITY,
|
|
10
|
+
account,
|
|
11
|
+
calls,
|
|
12
|
+
get,
|
|
13
|
+
installAdapter,
|
|
14
|
+
restoreAdapter,
|
|
15
|
+
wrapper,
|
|
16
|
+
type Route,
|
|
17
|
+
} from "../../../headless/_tests/communityHarness";
|
|
18
|
+
import { siteApp } from "../../../headless/_tests/siteAppHarness";
|
|
19
|
+
import type { SiteAppHomeSection } from "../../../../data/queries/useSiteApp";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The app home through the real hooks over a stub adapter: the configured
|
|
23
|
+
* order, hidden and empty sections left out, the portal always drawn, and the
|
|
24
|
+
* signed-out front door.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
afterEach(restoreAdapter);
|
|
28
|
+
|
|
29
|
+
const theme = { colors: { text: "#111111", background: "#ffffff", primary: "#6d28d9" }, cornerRadius: 8 };
|
|
30
|
+
|
|
31
|
+
function mount(node: ReactNode, opts: Parameters<typeof wrapper>[0] = {}) {
|
|
32
|
+
const Wrapper = wrapper({ community: null, ...opts });
|
|
33
|
+
return render(
|
|
34
|
+
<Wrapper>
|
|
35
|
+
<ForgeThemeProvider theme={theme}>{node}</ForgeThemeProvider>
|
|
36
|
+
</Wrapper>,
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const builtin = (key: string, visible = true) => ({ type: "builtin", key, visible }) as SiteAppHomeSection;
|
|
41
|
+
|
|
42
|
+
const summary = {
|
|
43
|
+
sections: {
|
|
44
|
+
projects: 1,
|
|
45
|
+
invoices: 1,
|
|
46
|
+
documents: 0,
|
|
47
|
+
bookings: 0,
|
|
48
|
+
memberships: 1,
|
|
49
|
+
courses: 0,
|
|
50
|
+
films: 0,
|
|
51
|
+
orders: 0,
|
|
52
|
+
tickets: 0,
|
|
53
|
+
waitlist: 0,
|
|
54
|
+
saved: 0,
|
|
55
|
+
messages: 0,
|
|
56
|
+
files: 0,
|
|
57
|
+
},
|
|
58
|
+
lastActivity: { work: null, library: null },
|
|
59
|
+
attention: [
|
|
60
|
+
{
|
|
61
|
+
kind: "invoice_due",
|
|
62
|
+
id: "inv-1",
|
|
63
|
+
invoiceNumber: "1042",
|
|
64
|
+
total: "450",
|
|
65
|
+
currency: "USD",
|
|
66
|
+
dueDate: null,
|
|
67
|
+
projectId: null,
|
|
68
|
+
projectName: null,
|
|
69
|
+
},
|
|
70
|
+
],
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const memberRoutes = (sections: SiteAppHomeSection[]): Route[] => [
|
|
74
|
+
get("/public/websites/app", () => siteApp({ appName: "Northlight Coaching", homeSections: sections })),
|
|
75
|
+
get("/public/portal/summary", () => summary),
|
|
76
|
+
get("/public/memberships/mine", () => [
|
|
77
|
+
{ community: { id: COMMUNITY.id, name: COMMUNITY.name, slug: COMMUNITY.slug }, membership: { id: "m-1", status: "active", tierName: "Free" } },
|
|
78
|
+
]),
|
|
79
|
+
get("/public/communities", () => [COMMUNITY, OTHER_COMMUNITY]),
|
|
80
|
+
get("/public/community/notifications", () => ({ data: [], unreadCount: 5 })),
|
|
81
|
+
get("/public/notifications/inbox", () => ({ data: [], unreadCount: 2 })),
|
|
82
|
+
get("/public/products", () => ({ data: [], total: 0 })),
|
|
83
|
+
];
|
|
84
|
+
|
|
85
|
+
const sectionOrder = () =>
|
|
86
|
+
Array.from(document.querySelectorAll("[data-testid^='app-section-']")).map((el) => el.getAttribute("data-testid"));
|
|
87
|
+
|
|
88
|
+
describe("AppHome, signed in", () => {
|
|
89
|
+
it("names the explore button by how a visitor gets in", async () => {
|
|
90
|
+
const extra = (slug: string, joinMode: string) => ({ ...OTHER_COMMUNITY, id: `c-${slug}`, slug, name: slug, joinMode });
|
|
91
|
+
// Routes answer in order, so this list wins over the default one.
|
|
92
|
+
installAdapter([
|
|
93
|
+
get("/public/communities", () => [COMMUNITY, extra("open-club", "open"), extra("apply-club", "application"), extra("invite-club", "invite")]),
|
|
94
|
+
...memberRoutes([builtin("explore_communities")]),
|
|
95
|
+
]);
|
|
96
|
+
mount(<AppHome />, { user: account() });
|
|
97
|
+
|
|
98
|
+
await screen.findByTestId("app-explore-apply-club-join");
|
|
99
|
+
expect(screen.getByTestId("app-explore-open-club-join").textContent).toBe("Join");
|
|
100
|
+
expect(screen.getByTestId("app-explore-apply-club-join").textContent).toBe("Apply");
|
|
101
|
+
expect(screen.getByTestId("app-explore-invite-club-join").textContent).toBe("Details");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("draws the visible sections in the configured order and leaves out the empty ones", async () => {
|
|
105
|
+
installAdapter(
|
|
106
|
+
memberRoutes([
|
|
107
|
+
builtin("needs_you"),
|
|
108
|
+
builtin("portal"),
|
|
109
|
+
builtin("communities"),
|
|
110
|
+
builtin("explore_communities"),
|
|
111
|
+
{ type: "link", id: "l1", label: "Book a discovery call", href: "/book", visible: true },
|
|
112
|
+
builtin("events", false),
|
|
113
|
+
builtin("shop"),
|
|
114
|
+
]),
|
|
115
|
+
);
|
|
116
|
+
mount(<AppHome />, { user: account() });
|
|
117
|
+
|
|
118
|
+
await screen.findByTestId("app-section-explore_communities");
|
|
119
|
+
expect(sectionOrder()).toEqual([
|
|
120
|
+
"app-section-needs_you",
|
|
121
|
+
"app-section-portal",
|
|
122
|
+
"app-section-communities",
|
|
123
|
+
"app-section-explore_communities",
|
|
124
|
+
"app-section-link",
|
|
125
|
+
]);
|
|
126
|
+
// Hidden: never fetched. Shop: configured, but the store is empty.
|
|
127
|
+
expect(calls("get", "/public/events")).toHaveLength(0);
|
|
128
|
+
expect(calls("get", "/public/products")).toHaveLength(1);
|
|
129
|
+
expect(screen.queryByTestId("app-section-shop")).toBeNull();
|
|
130
|
+
|
|
131
|
+
// The greeting and the header.
|
|
132
|
+
expect(screen.getByRole("heading", { level: 1 }).textContent).toMatch(/Fan$/);
|
|
133
|
+
expect(screen.getByTestId("app-home-lead").textContent).toBe("One thing is waiting for you.");
|
|
134
|
+
expect(screen.getByTestId("app-home-header").textContent).toContain("Northlight Coaching");
|
|
135
|
+
|
|
136
|
+
// Needs you: the exact thing, one tap away.
|
|
137
|
+
const needs = screen.getByTestId("app-section-needs_you");
|
|
138
|
+
expect(within(needs).getByTestId("app-attention-action").getAttribute("href")).toBe("/i/invoice/inv-1");
|
|
139
|
+
expect(within(needs).getByTestId("app-attention-action").textContent).toBe("Pay");
|
|
140
|
+
|
|
141
|
+
// The portal: a tile per section with anything in it, badges for what needs the person, then Settings.
|
|
142
|
+
const tiles = Array.from(screen.getByTestId("app-section-portal").querySelectorAll("a[data-testid^='app-portal-tile-']")).map(
|
|
143
|
+
(el) => el.getAttribute("data-testid"),
|
|
144
|
+
);
|
|
145
|
+
expect(tiles).toEqual(["app-portal-tile-projects", "app-portal-tile-invoices", "app-portal-tile-settings"]);
|
|
146
|
+
expect(screen.getByTestId("app-portal-tile-invoices-badge").textContent).toBe("1");
|
|
147
|
+
|
|
148
|
+
// Your communities, with unread; explore lists only the ones not joined.
|
|
149
|
+
await waitFor(() => expect(screen.getByTestId(`app-community-${COMMUNITY.slug}-unread`).textContent).toBe("5 new"));
|
|
150
|
+
expect(screen.getByTestId(`app-community-${COMMUNITY.slug}`).getAttribute("href")).toBe(`/i/communities/${COMMUNITY.slug}/home`);
|
|
151
|
+
expect(screen.queryByTestId(`app-explore-${COMMUNITY.slug}`)).toBeNull();
|
|
152
|
+
expect(screen.getByTestId(`app-explore-${OTHER_COMMUNITY.slug}-join`).getAttribute("href")).toBe(
|
|
153
|
+
`/i/communities/${OTHER_COMMUNITY.slug}`,
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
// The button names how a visitor gets in: an older server sends no joinMode, which reads as Join.
|
|
157
|
+
expect(screen.getByTestId(`app-explore-${OTHER_COMMUNITY.slug}-join`).textContent).toBe("Join");
|
|
158
|
+
|
|
159
|
+
// The link section is a labelled card to its address.
|
|
160
|
+
const linkCard = screen.getByTestId("app-section-link");
|
|
161
|
+
expect(linkCard.textContent).toContain("Book a discovery call");
|
|
162
|
+
expect(linkCard.getAttribute("href")).toBe("/book");
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("always draws the portal, even with nothing in it", async () => {
|
|
166
|
+
installAdapter([
|
|
167
|
+
get("/public/websites/app", () => siteApp({ homeSections: [builtin("needs_you"), builtin("portal"), builtin("communities")] })),
|
|
168
|
+
get("/public/portal/summary", () => ({ ...summary, sections: {}, attention: [] })),
|
|
169
|
+
get("/public/memberships/mine", () => []),
|
|
170
|
+
get("/public/communities", () => []),
|
|
171
|
+
get("/public/notifications/inbox", () => ({ data: [], unreadCount: 0 })),
|
|
172
|
+
]);
|
|
173
|
+
mount(<AppHome />, { user: account() });
|
|
174
|
+
await screen.findByTestId("app-section-portal");
|
|
175
|
+
expect(sectionOrder()).toEqual(["app-section-portal"]);
|
|
176
|
+
expect(screen.getByTestId("app-portal-tile-settings")).toBeTruthy();
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("navigates in-app through the adapter it is given", async () => {
|
|
180
|
+
installAdapter(memberRoutes([builtin("portal")]));
|
|
181
|
+
const navigate = vi.fn();
|
|
182
|
+
mount(<AppHome navigate={navigate} />, { user: account() });
|
|
183
|
+
fireEvent.click(await screen.findByTestId("app-portal-tile-projects"), { button: 0 });
|
|
184
|
+
expect(navigate).toHaveBeenCalledWith("/i/account/projects");
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
describe("AppHome, signed out", () => {
|
|
189
|
+
it("is the app's front door: its name, an email-first sign in, and a way to look around", async () => {
|
|
190
|
+
installAdapter([
|
|
191
|
+
get("/public/websites/app", () => siteApp({ appName: "Northlight Coaching" })),
|
|
192
|
+
(req) => (req.method === "post" && req.url === "/public/sessions/email/start" ? { ok: true } : undefined),
|
|
193
|
+
]);
|
|
194
|
+
mount(<AppHome />);
|
|
195
|
+
const door = await screen.findByTestId("app-home-signed-out");
|
|
196
|
+
expect(door.textContent).toContain("Northlight Coaching");
|
|
197
|
+
expect(screen.queryByTestId("app-section-portal")).toBeNull();
|
|
198
|
+
// Nothing member-shaped is asked for.
|
|
199
|
+
expect(calls("get", "/public/portal/summary")).toHaveLength(0);
|
|
200
|
+
|
|
201
|
+
const links = ["communities", "events", "courses", "shop"].map((key) => screen.getByTestId(`app-explore-link-${key}`).getAttribute("href"));
|
|
202
|
+
expect(links).toEqual(["/i/communities", "/i/events", "/i/courses", "/i/store"]);
|
|
203
|
+
|
|
204
|
+
fireEvent.change(within(screen.getByTestId("app-sign-in-email")).getByRole("textbox"), { target: { value: "fan@example.com" } });
|
|
205
|
+
fireEvent.submit(screen.getByTestId("app-sign-in-email"));
|
|
206
|
+
await screen.findByTestId("app-sign-in-code");
|
|
207
|
+
expect(calls("post", "/public/sessions/email/start")[0]!.body).toMatchObject({ email: "fan@example.com" });
|
|
208
|
+
});
|
|
209
|
+
});
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
4
|
+
import type { ReactNode } from "react";
|
|
5
|
+
import { ForgeThemeProvider } from "../../../theme/ForgeThemeProvider";
|
|
6
|
+
import { AppPrompts } from "../AppPrompts";
|
|
7
|
+
import { account, calls, get, installAdapter, restoreAdapter, wrapper, type Route } from "../../../headless/_tests/communityHarness";
|
|
8
|
+
import { siteApp } from "../../../headless/_tests/siteAppHarness";
|
|
9
|
+
import { clearPendingAppInstall, requestAppInstall } from "../../../headless/siteApp/appPrompts";
|
|
10
|
+
import { resetInstallPromptForTests } from "../../../../data/queries/usePWA";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The site app's prompts as drawn: the install sheet after a success, worded
|
|
14
|
+
* for it, native or iPhone steps, never in the installed app, never when the
|
|
15
|
+
* creator turned it off, not again for 14 days after Not now; and the
|
|
16
|
+
* notifications sheet on the first launch of the installed app.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const theme = { colors: { text: "#111111", background: "#ffffff", primary: "#6d28d9" }, cornerRadius: 8 };
|
|
20
|
+
const IPHONE = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 Version/17.0 Mobile/15E148 Safari/604.1";
|
|
21
|
+
const DESKTOP = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/120.0 Safari/537.36";
|
|
22
|
+
|
|
23
|
+
function mount(node: ReactNode, opts: Parameters<typeof wrapper>[0] = {}) {
|
|
24
|
+
const Wrapper = wrapper({ community: null, ...opts });
|
|
25
|
+
return render(
|
|
26
|
+
<Wrapper>
|
|
27
|
+
<ForgeThemeProvider theme={theme}>{node}</ForgeThemeProvider>
|
|
28
|
+
</Wrapper>,
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const realMatchMedia = window.matchMedia;
|
|
33
|
+
const realUserAgent = Object.getOwnPropertyDescriptor(window.navigator, "userAgent");
|
|
34
|
+
|
|
35
|
+
function setUserAgent(value: string) {
|
|
36
|
+
Object.defineProperty(window.navigator, "userAgent", { value, configurable: true });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function setStandalone(standalone: boolean) {
|
|
40
|
+
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
|
|
41
|
+
matches: standalone && query.includes("display-mode: standalone"),
|
|
42
|
+
media: query,
|
|
43
|
+
addEventListener: vi.fn(),
|
|
44
|
+
removeEventListener: vi.fn(),
|
|
45
|
+
})) as never;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
beforeEach(() => {
|
|
49
|
+
window.localStorage.clear();
|
|
50
|
+
window.sessionStorage.clear();
|
|
51
|
+
clearPendingAppInstall();
|
|
52
|
+
resetInstallPromptForTests(null);
|
|
53
|
+
setStandalone(false);
|
|
54
|
+
setUserAgent(IPHONE);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
afterEach(() => {
|
|
58
|
+
restoreAdapter();
|
|
59
|
+
window.matchMedia = realMatchMedia;
|
|
60
|
+
if (realUserAgent) Object.defineProperty(window.navigator, "userAgent", realUserAgent);
|
|
61
|
+
else delete (window.navigator as { userAgent?: string }).userAgent;
|
|
62
|
+
vi.unstubAllGlobals();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const settings = (patch: Parameters<typeof siteApp>[0] = {}): Route => get("/public/websites/app", () => siteApp(patch));
|
|
66
|
+
|
|
67
|
+
/** Let the settings arrive and the prompt decide. */
|
|
68
|
+
const settle = () => waitFor(() => expect(calls("get", "/public/websites/app").length).toBeGreaterThan(0));
|
|
69
|
+
|
|
70
|
+
describe("the install prompt", () => {
|
|
71
|
+
it("on an iPhone, shows the two Safari steps, worded for joining", async () => {
|
|
72
|
+
installAdapter([settings()]);
|
|
73
|
+
mount(<AppPrompts />);
|
|
74
|
+
act(() => requestAppInstall("joined_community"));
|
|
75
|
+
const sheet = await screen.findByTestId("app-install-ios");
|
|
76
|
+
expect(sheet.textContent).toContain("You're in. Keep it one tap away.");
|
|
77
|
+
expect(screen.getByTestId("app-install-ios-steps").textContent).toContain("Tap Share in the toolbar");
|
|
78
|
+
expect(screen.getByTestId("app-install-ios-steps").textContent).toContain("Choose Add to Home Screen");
|
|
79
|
+
// Safari cannot install for us, so there is no Install button.
|
|
80
|
+
expect(screen.queryByTestId("app-install-accept")).toBeNull();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("is worded for the moment it was asked in", async () => {
|
|
84
|
+
installAdapter([settings()]);
|
|
85
|
+
mount(<AppPrompts />);
|
|
86
|
+
act(() => requestAppInstall("booking"));
|
|
87
|
+
expect((await screen.findByTestId("app-install-ios")).textContent).toContain("Booked. Keep your sessions one tap away.");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("on Android and desktop, fires the browser's own prompt it held", async () => {
|
|
91
|
+
setUserAgent(DESKTOP);
|
|
92
|
+
const prompt = vi.fn().mockResolvedValue(undefined);
|
|
93
|
+
const event = Object.assign(new Event("beforeinstallprompt"), {
|
|
94
|
+
prompt,
|
|
95
|
+
userChoice: Promise.resolve({ outcome: "accepted" as const }),
|
|
96
|
+
});
|
|
97
|
+
resetInstallPromptForTests(event);
|
|
98
|
+
installAdapter([settings()]);
|
|
99
|
+
mount(<AppPrompts />);
|
|
100
|
+
act(() => requestAppInstall("purchase"));
|
|
101
|
+
const sheet = await screen.findByTestId("app-install-native");
|
|
102
|
+
expect(sheet.textContent).toContain("Thank you. Keep your orders one tap away.");
|
|
103
|
+
fireEvent.click(screen.getByTestId("app-install-accept"));
|
|
104
|
+
expect(prompt).toHaveBeenCalledTimes(1);
|
|
105
|
+
await waitFor(() => expect(screen.queryByTestId("app-install-native")).toBeNull());
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("picks up a request made on the page before (it rides the session)", async () => {
|
|
109
|
+
window.sessionStorage.setItem("forge-app-install-pending", JSON.stringify({ context: "invite_accepted", at: Date.now() }));
|
|
110
|
+
installAdapter([settings()]);
|
|
111
|
+
mount(<AppPrompts />);
|
|
112
|
+
expect((await screen.findByTestId("app-install-ios")).textContent).toContain("Keep your projects one tap away.");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("never shows in the installed app", async () => {
|
|
116
|
+
setStandalone(true);
|
|
117
|
+
installAdapter([settings()]);
|
|
118
|
+
mount(<AppPrompts />);
|
|
119
|
+
act(() => requestAppInstall("joined_community"));
|
|
120
|
+
await settle();
|
|
121
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
122
|
+
expect(screen.queryByTestId("app-install-ios")).toBeNull();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("never shows when the creator turned it off", async () => {
|
|
126
|
+
installAdapter([settings({ installPrompt: false })]);
|
|
127
|
+
mount(<AppPrompts />);
|
|
128
|
+
act(() => requestAppInstall("joined_community"));
|
|
129
|
+
await settle();
|
|
130
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
131
|
+
expect(screen.queryByTestId("app-install-ios")).toBeNull();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("remembers Not now for 14 days", async () => {
|
|
135
|
+
installAdapter([settings()]);
|
|
136
|
+
mount(<AppPrompts />);
|
|
137
|
+
act(() => requestAppInstall("joined_community"));
|
|
138
|
+
fireEvent.click(await screen.findByTestId("app-install-not-now"));
|
|
139
|
+
await waitFor(() => expect(screen.queryByTestId("app-install-ios")).toBeNull());
|
|
140
|
+
const at = Number(window.localStorage.getItem("forge-app-install-dismissed-at"));
|
|
141
|
+
expect(Date.now() - at).toBeLessThan(5_000);
|
|
142
|
+
|
|
143
|
+
act(() => requestAppInstall("purchase"));
|
|
144
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
145
|
+
expect(screen.queryByTestId("app-install-ios")).toBeNull();
|
|
146
|
+
|
|
147
|
+
// Fifteen days on, it may ask again.
|
|
148
|
+
window.localStorage.setItem("forge-app-install-dismissed-at", String(Date.now() - 15 * 24 * 60 * 60 * 1000));
|
|
149
|
+
act(() => requestAppInstall("purchase"));
|
|
150
|
+
expect(await screen.findByTestId("app-install-ios")).toBeTruthy();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("draws once when two hosts are mounted", async () => {
|
|
154
|
+
installAdapter([settings()]);
|
|
155
|
+
mount(
|
|
156
|
+
<>
|
|
157
|
+
<AppPrompts />
|
|
158
|
+
<AppPrompts />
|
|
159
|
+
</>,
|
|
160
|
+
);
|
|
161
|
+
act(() => requestAppInstall("joined_community"));
|
|
162
|
+
await screen.findByTestId("app-install-ios");
|
|
163
|
+
expect(screen.getAllByTestId("app-install-ios")).toHaveLength(1);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
describe("the notifications prompt", () => {
|
|
168
|
+
function stubPush() {
|
|
169
|
+
vi.stubGlobal("Notification", { permission: "default", requestPermission: vi.fn().mockResolvedValue("denied") });
|
|
170
|
+
vi.stubGlobal("PushManager", function PushManager() {});
|
|
171
|
+
Object.defineProperty(window.navigator, "serviceWorker", {
|
|
172
|
+
configurable: true,
|
|
173
|
+
value: { ready: Promise.resolve({ pushManager: { getSubscription: async () => null } }) },
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
afterEach(() => {
|
|
178
|
+
delete (window.navigator as { serviceWorker?: unknown }).serviceWorker;
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
const matrix = get("/public/notifications/preferences", () => ({
|
|
182
|
+
preferences: { "dm.push": true, "new_post.push": false },
|
|
183
|
+
overrides: {},
|
|
184
|
+
digestCadence: "daily",
|
|
185
|
+
defaults: {
|
|
186
|
+
dm: { inapp: true, push: true, email: true, emailMode: "instant" },
|
|
187
|
+
new_post: { inapp: true, push: false, email: true, emailMode: "digest" },
|
|
188
|
+
},
|
|
189
|
+
}));
|
|
190
|
+
|
|
191
|
+
it("offers the categories the member's settings know, on the first launch of the installed app", async () => {
|
|
192
|
+
stubPush();
|
|
193
|
+
setStandalone(true);
|
|
194
|
+
installAdapter([settings(), matrix]);
|
|
195
|
+
mount(<AppPrompts />, { user: account() });
|
|
196
|
+
const sheet = await screen.findByTestId("app-push-prompt");
|
|
197
|
+
expect(sheet.textContent).toContain("Don't miss what matters");
|
|
198
|
+
expect(screen.getByTestId("app-push-category-messages")).toBeTruthy();
|
|
199
|
+
expect(screen.getByTestId("app-push-category-community")).toBeTruthy();
|
|
200
|
+
// No event types in this matrix: no events row.
|
|
201
|
+
expect(screen.queryByTestId("app-push-category-events")).toBeNull();
|
|
202
|
+
|
|
203
|
+
fireEvent.click(screen.getByTestId("app-push-later"));
|
|
204
|
+
await waitFor(() => expect(screen.queryByTestId("app-push-prompt")).toBeNull());
|
|
205
|
+
expect(window.localStorage.getItem("forge-app-push-answer")).toBe("declined");
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("is not offered in a browser tab, when turned off, or once answered", async () => {
|
|
209
|
+
stubPush();
|
|
210
|
+
installAdapter([settings(), matrix]);
|
|
211
|
+
const tab = mount(<AppPrompts />, { user: account() });
|
|
212
|
+
await settle();
|
|
213
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
214
|
+
expect(screen.queryByTestId("app-push-prompt")).toBeNull();
|
|
215
|
+
tab.unmount();
|
|
216
|
+
|
|
217
|
+
setStandalone(true);
|
|
218
|
+
installAdapter([settings({ pushPrompt: false }), matrix]);
|
|
219
|
+
const off = mount(<AppPrompts />, { user: account() });
|
|
220
|
+
await settle();
|
|
221
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
222
|
+
expect(screen.queryByTestId("app-push-prompt")).toBeNull();
|
|
223
|
+
off.unmount();
|
|
224
|
+
|
|
225
|
+
window.localStorage.setItem("forge-app-push-answer", "accepted");
|
|
226
|
+
installAdapter([settings(), matrix]);
|
|
227
|
+
mount(<AppPrompts />, { user: account() });
|
|
228
|
+
await settle();
|
|
229
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
230
|
+
expect(screen.queryByTestId("app-push-prompt")).toBeNull();
|
|
231
|
+
});
|
|
232
|
+
});
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// The site app (one installable app per website): its home, its bottom bar,
|
|
2
|
+
// the way back to its Home from another shell, and its install and
|
|
3
|
+
// notification prompts.
|
|
4
|
+
export { AppHome, type AppHomeProps } from "./AppHome";
|
|
5
|
+
export { AppBar, APP_BAR_HEIGHT, type AppBarProps } from "./AppBar";
|
|
6
|
+
export { AppPrompts } from "./AppPrompts";
|
|
7
|
+
export { AppHomeMark, type AppHomeMarkProps } from "./AppHomeMark";
|
|
@@ -5,6 +5,8 @@ import { useMessageThread } from "../../headless/chat/useMessageThread";
|
|
|
5
5
|
import { useForgeTheme } from "../../theme/ForgeThemeProvider";
|
|
6
6
|
import { MessageBubble } from "./MessageBubble";
|
|
7
7
|
import { ChatComposer } from "./ChatComposer";
|
|
8
|
+
import { CommunityAgentThinking } from "../community/CommunityAgent";
|
|
9
|
+
import { useCommunityAgentAnswerRatings } from "../../headless/community/useCommunityAgent";
|
|
8
10
|
|
|
9
11
|
export interface ChatThreadPanelProps {
|
|
10
12
|
parentMessage: ChatMessage;
|
|
@@ -13,10 +15,14 @@ export interface ChatThreadPanelProps {
|
|
|
13
15
|
onClose: () => void;
|
|
14
16
|
onMarkAsRead?: (messageIds: string[]) => void;
|
|
15
17
|
border: string;
|
|
18
|
+
/** The community AI is writing an answer in this thread. */
|
|
19
|
+
agentThinking?: boolean;
|
|
20
|
+
/** The community AI's name, for its "thinking" line. */
|
|
21
|
+
agentName?: string | null;
|
|
16
22
|
}
|
|
17
23
|
|
|
18
24
|
/**
|
|
19
|
-
* One-level thread panel
|
|
25
|
+
* One-level thread panel: a side panel on desktop, full-screen overlay on
|
|
20
26
|
* mobile. Renders the parent, its replies, and a reply composer.
|
|
21
27
|
*/
|
|
22
28
|
export function ChatThreadPanel({
|
|
@@ -26,9 +32,16 @@ export function ChatThreadPanel({
|
|
|
26
32
|
onClose,
|
|
27
33
|
onMarkAsRead,
|
|
28
34
|
border,
|
|
35
|
+
agentThinking = false,
|
|
36
|
+
agentName,
|
|
29
37
|
}: ChatThreadPanelProps) {
|
|
30
38
|
const theme = useForgeTheme();
|
|
31
39
|
const thread = useMessageThread({ parentMessage, socket });
|
|
40
|
+
// The viewer's own ratings on the AI answers in this thread.
|
|
41
|
+
const ratings = useCommunityAgentAnswerRatings(
|
|
42
|
+
"chat_message",
|
|
43
|
+
thread.replies.filter((m) => m.senderAgentId).map((m) => m.id),
|
|
44
|
+
);
|
|
32
45
|
|
|
33
46
|
// Thread replies count toward the channel's unread total but never appear in
|
|
34
47
|
// the main timeline (which is where reads are normally marked). Mark the
|
|
@@ -63,7 +76,11 @@ export function ChatThreadPanel({
|
|
|
63
76
|
</header>
|
|
64
77
|
|
|
65
78
|
<div style={{ padding: 16, borderBottom: border }}>
|
|
66
|
-
<MessageBubble
|
|
79
|
+
<MessageBubble
|
|
80
|
+
message={parentMessage}
|
|
81
|
+
mine={!!parentMessage.senderAccountId && parentMessage.senderAccountId === currentAccountId}
|
|
82
|
+
showThreadControls={false}
|
|
83
|
+
/>
|
|
67
84
|
</div>
|
|
68
85
|
|
|
69
86
|
<div style={{ flex: 1, overflowY: "auto", padding: 16, display: "flex", flexDirection: "column", gap: 10 }}>
|
|
@@ -76,9 +93,17 @@ export function ChatThreadPanel({
|
|
|
76
93
|
</button>
|
|
77
94
|
)}
|
|
78
95
|
{thread.replies.map((m) => (
|
|
79
|
-
<MessageBubble
|
|
96
|
+
<MessageBubble
|
|
97
|
+
key={m.id}
|
|
98
|
+
message={m}
|
|
99
|
+
mine={!!m.senderAccountId && m.senderAccountId === currentAccountId}
|
|
100
|
+
showThreadControls={false}
|
|
101
|
+
showAgentFeedback={!!m.senderAgentId && ratings.canRate(m.id)}
|
|
102
|
+
savedAgentRating={ratings.ratingOf(m.id)}
|
|
103
|
+
/>
|
|
80
104
|
))}
|
|
81
|
-
{
|
|
105
|
+
{agentThinking && <CommunityAgentThinking name={agentName} />}
|
|
106
|
+
{!thread.isLoading && thread.replies.length === 0 && !agentThinking && (
|
|
82
107
|
<div style={{ opacity: 0.5, fontSize: 13, textAlign: "center" }}>No replies yet. Start the thread.</div>
|
|
83
108
|
)}
|
|
84
109
|
</div>
|
|
@@ -2,6 +2,12 @@ import type { ChatMessage } from "../../../data/queries/useChat";
|
|
|
2
2
|
import { useForgeTheme } from "../../theme/ForgeThemeProvider";
|
|
3
3
|
import { readableTextOn } from "../../theme/contrast";
|
|
4
4
|
import { ChatAttachments } from "./ChatAttachments";
|
|
5
|
+
import {
|
|
6
|
+
CommunityAgentAvatar,
|
|
7
|
+
CommunityAgentFeedback,
|
|
8
|
+
CommunityAgentName,
|
|
9
|
+
CommunityLinkifiedText,
|
|
10
|
+
} from "../community/CommunityAgent";
|
|
5
11
|
|
|
6
12
|
export interface MessageBubbleProps {
|
|
7
13
|
message: ChatMessage;
|
|
@@ -9,18 +15,44 @@ export interface MessageBubbleProps {
|
|
|
9
15
|
/** Thread affordances hidden inside an already-open thread. */
|
|
10
16
|
onOpenThread?: (message: ChatMessage) => void;
|
|
11
17
|
showThreadControls?: boolean;
|
|
18
|
+
/** An answer from the community AI to this viewer's question: offer thumbs up / down. */
|
|
19
|
+
showAgentFeedback?: boolean;
|
|
20
|
+
/** The viewer's saved rating on this answer. */
|
|
21
|
+
savedAgentRating?: 1 | -1 | null;
|
|
12
22
|
}
|
|
13
23
|
|
|
14
|
-
export function MessageBubble({
|
|
24
|
+
export function MessageBubble({
|
|
25
|
+
message,
|
|
26
|
+
mine: mineProp,
|
|
27
|
+
onOpenThread,
|
|
28
|
+
showThreadControls = true,
|
|
29
|
+
showAgentFeedback = false,
|
|
30
|
+
savedAgentRating = null,
|
|
31
|
+
}: MessageBubbleProps) {
|
|
15
32
|
const theme = useForgeTheme();
|
|
16
33
|
const replyCount = message.replyCount ?? 0;
|
|
34
|
+
// The community AI sent it: its name and badge, never on "my" side.
|
|
35
|
+
const agent = message.senderAgentId
|
|
36
|
+
? { name: message.senderFirstName || "", avatarUrl: message.senderAgentAvatarUrl ?? null }
|
|
37
|
+
: null;
|
|
38
|
+
const mine = mineProp && !agent;
|
|
17
39
|
|
|
18
40
|
return (
|
|
19
|
-
<div
|
|
20
|
-
{
|
|
21
|
-
|
|
22
|
-
|
|
41
|
+
<div
|
|
42
|
+
style={{ alignSelf: mine ? "flex-end" : "flex-start", maxWidth: "70%" }}
|
|
43
|
+
data-testid={agent ? "chat-agent-message" : undefined}
|
|
44
|
+
>
|
|
45
|
+
{agent ? (
|
|
46
|
+
<div style={{ display: "flex", alignItems: "center", gap: 6, marginBottom: 3 }}>
|
|
47
|
+
<CommunityAgentAvatar agent={agent} size={20} />
|
|
48
|
+
<CommunityAgentName agent={agent} fontSize={12} />
|
|
23
49
|
</div>
|
|
50
|
+
) : (
|
|
51
|
+
!mine && (
|
|
52
|
+
<div style={{ fontSize: 12, opacity: 0.6, marginBottom: 2 }}>
|
|
53
|
+
{message.senderFirstName} {message.senderLastName}
|
|
54
|
+
</div>
|
|
55
|
+
)
|
|
24
56
|
)}
|
|
25
57
|
<div
|
|
26
58
|
style={{
|
|
@@ -30,9 +62,14 @@ export function MessageBubble({ message, mine, onOpenThread, showThreadControls
|
|
|
30
62
|
borderRadius: theme.cornerRadius,
|
|
31
63
|
}}
|
|
32
64
|
>
|
|
33
|
-
{message.content}
|
|
65
|
+
{agent && message.content ? <CommunityLinkifiedText text={message.content} /> : message.content}
|
|
34
66
|
<ChatAttachments attachments={message.attachments} />
|
|
35
67
|
</div>
|
|
68
|
+
{showAgentFeedback && agent && (
|
|
69
|
+
<div style={{ marginTop: 3 }}>
|
|
70
|
+
<CommunityAgentFeedback entityType="chat_message" entityId={message.id} saved={savedAgentRating} />
|
|
71
|
+
</div>
|
|
72
|
+
)}
|
|
36
73
|
|
|
37
74
|
{showThreadControls && (
|
|
38
75
|
<div style={{ display: "flex", gap: 10, marginTop: 3 }}>
|