@tribe-nest/forge 3.58.0 → 3.60.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 +1 -1
- package/src/_tests/testCommunity.tsx +28 -0
- package/src/contexts/CommunityContext.tsx +192 -0
- package/src/data/queries/useChat.ts +42 -39
- package/src/data/queries/useCommunities.ts +343 -0
- package/src/data/queries/useCommunity.ts +93 -89
- package/src/data/queries/useMembership.ts +32 -7
- package/src/data/queries/useMembershipCalls.ts +116 -7
- package/src/data/queries/usePostCollections.ts +28 -16
- package/src/data/queries/usePosts.ts +16 -14
- package/src/data/queries/useSubscriptions.ts +22 -8
- package/src/i18n/de.json +145 -1
- package/src/i18n/en.json +145 -1
- package/src/index.ts +19 -7
- package/src/runtime/pages.spec.ts +31 -0
- package/src/runtime/pages.ts +21 -22
- package/src/server/_tests/buildWebManifest.spec.ts +1 -1
- package/src/server/_tests/forgeServiceWorkerScript.spec.ts +4 -4
- package/src/ui/format/_tests/membershipGate.spec.ts +6 -6
- package/src/ui/format/membershipGate.ts +19 -15
- package/src/ui/headless/_tests/communityGate.spec.tsx +80 -0
- package/src/ui/headless/_tests/communityHarness.tsx +205 -0
- package/src/ui/headless/_tests/communityProvider.spec.tsx +151 -0
- package/src/ui/headless/_tests/useCommunityInvite.spec.tsx +104 -0
- package/src/ui/headless/_tests/useCommunityJoin.spec.tsx +382 -0
- package/src/ui/headless/_tests/useCommunityMembership.spec.tsx +151 -0
- package/src/ui/headless/_tests/useCommunityReads.spec.tsx +286 -0
- package/src/ui/headless/_tests/useCommunityUpgrade.spec.tsx +157 -0
- package/src/ui/headless/auth/useSignupForm.ts +10 -2
- package/src/ui/headless/communities/CommunityGate.tsx +67 -0
- package/src/ui/headless/communities/index.ts +55 -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/useCommunityHome.ts +88 -0
- package/src/ui/headless/communities/useCommunityInvite.ts +116 -0
- package/src/ui/headless/communities/useCommunityJoin.ts +444 -0
- package/src/ui/headless/communities/useCommunityMembership.ts +136 -0
- package/src/ui/headless/communities/useCommunityUpgrade.ts +213 -0
- package/src/ui/headless/communities/useMyMemberships.ts +59 -0
- package/src/ui/headless/community/useCommunityAttachmentUpload.ts +7 -3
- package/src/ui/headless/index.ts +8 -4
- package/src/ui/headless/memberHome/index.ts +1 -1
- package/src/ui/headless/memberHome/useMemberHomeNav.ts +44 -42
- 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/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/index.ts +17 -12
- package/src/ui/styled/AccountDashboard.tsx +48 -256
- 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/Paywall.tsx +1 -1
- package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +1 -1
- 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/CourseCallsSection.spec.tsx +7 -1
- 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/communities/CommunitiesList.tsx +88 -0
- package/src/ui/styled/communities/CommunityAbout.tsx +140 -0
- package/src/ui/styled/communities/CommunityHome.tsx +175 -0
- package/src/ui/styled/communities/CommunityJoinSheet.tsx +444 -0
- package/src/ui/styled/communities/CommunityMembershipMenu.tsx +133 -0
- package/src/ui/styled/communities/CommunityPending.tsx +49 -0
- package/src/ui/styled/communities/CommunityUpgradeSheet.tsx +137 -0
- package/src/ui/styled/communities/MyMembershipsList.tsx +89 -0
- package/src/ui/styled/communities/_tests/CommunityPages.spec.tsx +102 -0
- package/src/ui/styled/communities/index.ts +10 -0
- package/src/ui/styled/communities/shared.tsx +149 -0
- package/src/ui/styled/community/_tests/CommunityComposer.spec.tsx +4 -1
- package/src/ui/styled/community/_tests/CommunityPostDetail.spec.tsx +4 -1
- package/src/ui/styled/forge-utilities.css +19 -0
- package/src/ui/styled/members/CallTranscriptViewer.tsx +424 -0
- package/src/ui/styled/members/MemberCallsCalendar.tsx +142 -10
- package/src/ui/styled/members/MemberHomeLayout.tsx +35 -17
- package/src/ui/styled/members/MemberPostCard.tsx +9 -5
- 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/utils/_tests/safeRedirect.spec.ts +2 -2
- 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
package/package.json
CHANGED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { CommunityProvider } from "../contexts/CommunityContext";
|
|
3
|
+
import type { PublicCommunity } from "../data/queries/useCommunities";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A community for specs. Member-content hooks throw outside a
|
|
7
|
+
* `<CommunityProvider>`, so a spec that renders one wraps it in `InCommunity`.
|
|
8
|
+
* `initialData` makes the id known on the first render, with no request.
|
|
9
|
+
*/
|
|
10
|
+
export const TEST_COMMUNITY: PublicCommunity = {
|
|
11
|
+
id: "11111111-1111-4111-8111-111111111111",
|
|
12
|
+
name: "Fan Club",
|
|
13
|
+
slug: "fan-club",
|
|
14
|
+
description: "Where the fans are.",
|
|
15
|
+
coverUrl: null,
|
|
16
|
+
logoUrl: null,
|
|
17
|
+
category: "Music",
|
|
18
|
+
tags: [],
|
|
19
|
+
memberCount: 42,
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export function InCommunity({ children, community = TEST_COMMUNITY }: { children: ReactNode; community?: PublicCommunity }) {
|
|
23
|
+
return (
|
|
24
|
+
<CommunityProvider community={community.slug} initialData={community}>
|
|
25
|
+
{children}
|
|
26
|
+
</CommunityProvider>
|
|
27
|
+
);
|
|
28
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useContext, useEffect, useMemo, type ReactNode } from "react";
|
|
3
|
+
import { useQueryClient } from "@tanstack/react-query";
|
|
4
|
+
import { createSharedContext } from "../runtime/registry";
|
|
5
|
+
import { useForge } from "../provider/ForgeProvider";
|
|
6
|
+
import {
|
|
7
|
+
communitiesKeys,
|
|
8
|
+
usePublicCommunity,
|
|
9
|
+
useViewerAccountId,
|
|
10
|
+
type CommunityDetail,
|
|
11
|
+
type PublicCommunity,
|
|
12
|
+
} from "../data/queries/useCommunities";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Which community the surrounding UI is about.
|
|
16
|
+
*
|
|
17
|
+
* A profile can run several communities, each fully isolated (members, tiers,
|
|
18
|
+
* feed, spaces, collections, chat, calls, identity). So every member-content
|
|
19
|
+
* hook has to be told WHICH one, and it is told by this provider rather than by
|
|
20
|
+
* an argument threaded through every component: the hosted pages fill it from
|
|
21
|
+
* the `$slug` in the URL, and a creator's own page wraps a section in it.
|
|
22
|
+
*
|
|
23
|
+
* <CommunityProvider community="fan-club">
|
|
24
|
+
* <CommunityHome />
|
|
25
|
+
* </CommunityProvider>
|
|
26
|
+
*
|
|
27
|
+
* A member-content hook used outside it THROWS. That is deliberate: guessing
|
|
28
|
+
* ("the first community", "any membership on the profile") is exactly the
|
|
29
|
+
* cross-community leak this model exists to prevent, and a developer error at
|
|
30
|
+
* build time is far cheaper than a fan reading the wrong community's feed.
|
|
31
|
+
*
|
|
32
|
+
* The context is shared across bundles (`createSharedContext`), so a provider a
|
|
33
|
+
* site mounts is the one the hosted pages bundle reads, and vice versa.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
export interface CommunityContextValue {
|
|
37
|
+
/** The community, once loaded. */
|
|
38
|
+
community: PublicCommunity | undefined;
|
|
39
|
+
/** Its id. Known before the community loads when the provider was given an id. */
|
|
40
|
+
communityId: string | undefined;
|
|
41
|
+
/** Its slug. Known before the community loads when the provider was given a slug. */
|
|
42
|
+
slug: string | undefined;
|
|
43
|
+
isLoading: boolean;
|
|
44
|
+
/** True when the server answered that no such live community exists. */
|
|
45
|
+
notFound: boolean;
|
|
46
|
+
/** What the provider was given: a slug or an id. */
|
|
47
|
+
ref: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const CommunityContext = createSharedContext<CommunityContextValue | null>("community", null);
|
|
51
|
+
|
|
52
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
53
|
+
|
|
54
|
+
/** True when a community reference is an id rather than a slug. */
|
|
55
|
+
export function isCommunityId(ref: string): boolean {
|
|
56
|
+
return UUID_RE.test(ref);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface CommunityProviderProps {
|
|
60
|
+
/** The community's slug (as in `/i/communities/<slug>`) or its id. */
|
|
61
|
+
community: string;
|
|
62
|
+
/**
|
|
63
|
+
* The community when the host already has it (SSR, a list it came from).
|
|
64
|
+
* Seeds the cache so nothing below waits on a request. Optional.
|
|
65
|
+
*/
|
|
66
|
+
initialData?: PublicCommunity;
|
|
67
|
+
children: ReactNode;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Supplies one community to every hook and component below it. See the file
|
|
72
|
+
* docblock for why a member-content hook refuses to run without one.
|
|
73
|
+
*/
|
|
74
|
+
export function CommunityProvider({ community: ref, initialData, children }: CommunityProviderProps) {
|
|
75
|
+
const { profileId } = useForge();
|
|
76
|
+
const queryClient = useQueryClient();
|
|
77
|
+
const accountId = useViewerAccountId();
|
|
78
|
+
|
|
79
|
+
// Seed the detail cache from `initialData` so `community` is there on the
|
|
80
|
+
// first render. The seed carries no tiers or viewer, so the real read still
|
|
81
|
+
// runs and replaces it.
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
if (!initialData || !profileId) return;
|
|
84
|
+
const key = communitiesKeys.detail(profileId, ref, {}, accountId);
|
|
85
|
+
if (!queryClient.getQueryData(key)) {
|
|
86
|
+
queryClient.setQueryData<CommunityDetail>(
|
|
87
|
+
key,
|
|
88
|
+
{ community: initialData, tiers: [], viewer: EMPTY_VIEWER, invite: null },
|
|
89
|
+
{ updatedAt: 0 },
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
}, [initialData, profileId, ref, accountId, queryClient]);
|
|
93
|
+
|
|
94
|
+
const detail = usePublicCommunity(ref);
|
|
95
|
+
const community = detail.data?.community ?? initialData;
|
|
96
|
+
const byId = isCommunityId(ref);
|
|
97
|
+
|
|
98
|
+
const value = useMemo<CommunityContextValue>(
|
|
99
|
+
() => ({
|
|
100
|
+
community,
|
|
101
|
+
communityId: community?.id ?? (byId ? ref : undefined),
|
|
102
|
+
slug: community?.slug ?? (byId ? undefined : ref),
|
|
103
|
+
isLoading: !community && detail.isLoading,
|
|
104
|
+
notFound: !community && (detail.error as { response?: { status?: number } } | null)?.response?.status === 404,
|
|
105
|
+
ref,
|
|
106
|
+
}),
|
|
107
|
+
[community, byId, ref, detail.isLoading, detail.error],
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
return <CommunityContext.Provider value={value}>{children}</CommunityContext.Provider>;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const EMPTY_VIEWER = {
|
|
114
|
+
state: "none" as const,
|
|
115
|
+
membership: null,
|
|
116
|
+
application: null,
|
|
117
|
+
upgradeTiers: [],
|
|
118
|
+
canInteract: false,
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
/** Thrown by any member-content hook used outside `<CommunityProvider>`. */
|
|
122
|
+
export class MissingCommunityProviderError extends Error {
|
|
123
|
+
constructor(hookName: string) {
|
|
124
|
+
super(
|
|
125
|
+
`${hookName} must be used inside <CommunityProvider community="<slug or id>">. ` +
|
|
126
|
+
"A creator can run several communities, so Forge will not guess which one this is. " +
|
|
127
|
+
"Wrap the page (or section) in <CommunityProvider>, or pass `community` to the component that accepts it.",
|
|
128
|
+
);
|
|
129
|
+
this.name = "MissingCommunityProviderError";
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The surrounding community. Throws outside `<CommunityProvider>`.
|
|
135
|
+
*
|
|
136
|
+
* `communityId` can be undefined for a moment while a slug resolves, so data
|
|
137
|
+
* hooks gate on it rather than on the provider alone.
|
|
138
|
+
*/
|
|
139
|
+
export function useCommunityContext(): CommunityContextValue {
|
|
140
|
+
const ctx = useContext(CommunityContext);
|
|
141
|
+
if (!ctx) throw new MissingCommunityProviderError("useCommunityContext()");
|
|
142
|
+
return ctx;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** The surrounding community, or null outside a provider. For components that work either way. */
|
|
146
|
+
export function useOptionalCommunityContext(): CommunityContextValue | null {
|
|
147
|
+
return useContext(CommunityContext);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* What a member-content data hook needs: the API client, the profile and the
|
|
152
|
+
* community. Throws `MissingCommunityProviderError` naming the calling hook.
|
|
153
|
+
*/
|
|
154
|
+
export function useCommunityScope(hookName: string) {
|
|
155
|
+
const ctx = useContext(CommunityContext);
|
|
156
|
+
if (!ctx) throw new MissingCommunityProviderError(hookName);
|
|
157
|
+
const { client, profileId } = useForge();
|
|
158
|
+
return { client, profileId, communityId: ctx.communityId, slug: ctx.slug };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ---- The upgrade sheet opener ----------------------------------------------------
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Lets any locked item inside a community ("Unlock with {tier}") open the
|
|
165
|
+
* community's ONE upgrade sheet, wherever it is drawn. `CommunityHome` provides
|
|
166
|
+
* it; a custom member area can provide its own. Absent, locked items fall back
|
|
167
|
+
* to a link to the community's About page with the tier preselected.
|
|
168
|
+
*/
|
|
169
|
+
export interface CommunityUpgradeOpener {
|
|
170
|
+
/** Open the upgrade sheet, optionally preselecting the tier that unlocks the item. */
|
|
171
|
+
openUpgrade: (tierId?: string | null) => void;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const CommunityUpgradeOpenerContext = createSharedContext<CommunityUpgradeOpener | null>(
|
|
175
|
+
"communityUpgradeOpener",
|
|
176
|
+
null,
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
export function CommunityUpgradeOpenerProvider({
|
|
180
|
+
value,
|
|
181
|
+
children,
|
|
182
|
+
}: {
|
|
183
|
+
value: CommunityUpgradeOpener;
|
|
184
|
+
children: ReactNode;
|
|
185
|
+
}) {
|
|
186
|
+
return <CommunityUpgradeOpenerContext.Provider value={value}>{children}</CommunityUpgradeOpenerContext.Provider>;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** The surrounding upgrade opener, or null when no community home provides one. */
|
|
190
|
+
export function useCommunityUpgradeOpener(): CommunityUpgradeOpener | null {
|
|
191
|
+
return useContext(CommunityUpgradeOpenerContext);
|
|
192
|
+
}
|
|
@@ -2,6 +2,7 @@ import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tansta
|
|
|
2
2
|
import { useEffect, useRef, useState } from "react";
|
|
3
3
|
import { io, type Socket } from "socket.io-client";
|
|
4
4
|
import { useForge } from "../../provider/ForgeProvider";
|
|
5
|
+
import { useCommunityScope } from "../../contexts/CommunityContext";
|
|
5
6
|
import { getForgeWebSocketUrl } from "../../client/createForgeClient";
|
|
6
7
|
import { PUBLIC_ACCESS_TOKEN_KEY } from "../../contexts/PublicAuthContext";
|
|
7
8
|
|
|
@@ -67,83 +68,85 @@ export interface ChatMessagesPage {
|
|
|
67
68
|
// Query keys are exported so the realtime layer (and creators) can patch the
|
|
68
69
|
// same caches the data hooks populate.
|
|
69
70
|
export const chatKeys = {
|
|
70
|
-
|
|
71
|
+
// Channel lists, unread counts, DMs and the directory are per COMMUNITY: a
|
|
72
|
+
// profile runs several, and each has its own channels and members.
|
|
73
|
+
channels: (communityId?: string) => ["public-chat-channels", communityId] as const,
|
|
71
74
|
messages: (channelId?: string) => ["public-chat-messages", channelId] as const,
|
|
72
75
|
pinned: (channelId?: string) => ["public-chat-pinned", channelId] as const,
|
|
73
|
-
unread: (
|
|
74
|
-
dm: (
|
|
76
|
+
unread: (communityId?: string) => ["public-chat-unread-counts", communityId] as const,
|
|
77
|
+
dm: (communityId?: string) => ["public-chat-dm", communityId] as const,
|
|
75
78
|
replies: (parentMessageId?: string) => ["public-chat-replies", parentMessageId] as const,
|
|
76
|
-
memberDirectory: (
|
|
79
|
+
memberDirectory: (communityId?: string) => ["public-chat-member-directory", communityId] as const,
|
|
77
80
|
};
|
|
78
81
|
|
|
79
82
|
// ── Data hooks ───────────────────────────────────────────────────────────────
|
|
80
83
|
|
|
81
84
|
/** Channels visible to the current member (GET /public/chat/channels). */
|
|
82
85
|
export function useChatChannels() {
|
|
83
|
-
const { client, profileId } =
|
|
86
|
+
const { client, profileId, communityId } = useCommunityScope("useChatChannels()");
|
|
84
87
|
return useQuery<ChatChannel[]>({
|
|
85
|
-
queryKey: chatKeys.channels(
|
|
88
|
+
queryKey: chatKeys.channels(communityId),
|
|
86
89
|
queryFn: async () => {
|
|
87
|
-
const res = await client.get("/public/chat/channels", { params: { profileId } });
|
|
90
|
+
const res = await client.get("/public/chat/channels", { params: { profileId, communityId } });
|
|
88
91
|
return res.data;
|
|
89
92
|
},
|
|
90
|
-
enabled: !!profileId && !!client,
|
|
93
|
+
enabled: !!profileId && !!communityId && !!client,
|
|
91
94
|
});
|
|
92
95
|
}
|
|
93
96
|
|
|
94
97
|
/** Paginated messages for a channel, newest first (GET /public/chat/channels/:id/messages). */
|
|
95
98
|
export function useChatMessages(channelId?: string, limit = 50) {
|
|
96
|
-
const { client, profileId } =
|
|
99
|
+
const { client, profileId, communityId } = useCommunityScope("useChatMessages()");
|
|
97
100
|
return useInfiniteQuery<ChatMessagesPage>({
|
|
98
101
|
queryKey: chatKeys.messages(channelId),
|
|
99
102
|
queryFn: async ({ pageParam = 1 }) => {
|
|
100
103
|
const res = await client.get(`/public/chat/channels/${channelId}/messages`, {
|
|
101
|
-
params: { profileId, page: pageParam, limit },
|
|
104
|
+
params: { profileId, communityId, page: pageParam, limit },
|
|
102
105
|
});
|
|
103
106
|
return res.data;
|
|
104
107
|
},
|
|
105
108
|
initialPageParam: 1,
|
|
106
109
|
getNextPageParam: (lastPage) =>
|
|
107
110
|
lastPage.page * lastPage.limit < lastPage.total ? lastPage.page + 1 : undefined,
|
|
108
|
-
enabled: !!channelId && !!client && !!profileId,
|
|
111
|
+
enabled: !!channelId && !!client && !!profileId && !!communityId,
|
|
109
112
|
});
|
|
110
113
|
}
|
|
111
114
|
|
|
112
115
|
/** Pinned messages for a channel (GET /public/chat/channels/:id/pinned). */
|
|
113
116
|
export function useChatPinnedMessages(channelId?: string) {
|
|
114
|
-
const { client, profileId } =
|
|
117
|
+
const { client, profileId, communityId } = useCommunityScope("useChatPinnedMessages()");
|
|
115
118
|
return useQuery<ChatMessage[]>({
|
|
116
119
|
queryKey: chatKeys.pinned(channelId),
|
|
117
120
|
queryFn: async () => {
|
|
118
|
-
const res = await client.get(`/public/chat/channels/${channelId}/pinned`, { params: { profileId } });
|
|
121
|
+
const res = await client.get(`/public/chat/channels/${channelId}/pinned`, { params: { profileId, communityId } });
|
|
119
122
|
return res.data;
|
|
120
123
|
},
|
|
121
|
-
enabled: !!channelId && !!client && !!profileId,
|
|
124
|
+
enabled: !!channelId && !!client && !!profileId && !!communityId,
|
|
122
125
|
});
|
|
123
126
|
}
|
|
124
127
|
|
|
125
128
|
/** Per-channel unread counts, polled every 30s (GET /public/chat/unread-counts). */
|
|
126
129
|
export function useChatUnreadCounts() {
|
|
127
|
-
const { client, profileId } =
|
|
130
|
+
const { client, profileId, communityId } = useCommunityScope("useChatUnreadCounts()");
|
|
128
131
|
return useQuery<ChatUnreadCount[]>({
|
|
129
|
-
queryKey: chatKeys.unread(
|
|
132
|
+
queryKey: chatKeys.unread(communityId),
|
|
130
133
|
queryFn: async () => {
|
|
131
|
-
const res = await client.get("/public/chat/unread-counts", { params: { profileId } });
|
|
134
|
+
const res = await client.get("/public/chat/unread-counts", { params: { profileId, communityId } });
|
|
132
135
|
return res.data;
|
|
133
136
|
},
|
|
134
|
-
enabled: !!profileId && !!client,
|
|
137
|
+
enabled: !!profileId && !!communityId && !!client,
|
|
135
138
|
refetchInterval: 30000,
|
|
136
139
|
});
|
|
137
140
|
}
|
|
138
141
|
|
|
139
142
|
/** Mark messages as read (POST /public/chat/read-receipts); invalidates unread counts. */
|
|
140
143
|
export function useMarkChatRead() {
|
|
141
|
-
const { client, profileId } =
|
|
144
|
+
const { client, profileId, communityId } = useCommunityScope("useMarkChatRead()");
|
|
142
145
|
const queryClient = useQueryClient();
|
|
143
146
|
return useMutation({
|
|
144
147
|
mutationFn: async (messageIds: string[]) => {
|
|
145
148
|
if (messageIds.length === 0) return;
|
|
146
|
-
await client.post("/public/chat/read-receipts", { profileId, messageIds });
|
|
149
|
+
await client.post("/public/chat/read-receipts", { profileId, communityId, messageIds });
|
|
147
150
|
},
|
|
148
151
|
onSuccess: () => {
|
|
149
152
|
queryClient.invalidateQueries({ queryKey: ["public-chat-unread-counts"] });
|
|
@@ -153,7 +156,7 @@ export function useMarkChatRead() {
|
|
|
153
156
|
|
|
154
157
|
/** Send a message over REST (POST /public/chat/messages). Realtime path uses the socket. */
|
|
155
158
|
export function useSendChatMessage() {
|
|
156
|
-
const { client, profileId } =
|
|
159
|
+
const { client, profileId, communityId } = useCommunityScope("useSendChatMessage()");
|
|
157
160
|
return useMutation({
|
|
158
161
|
mutationFn: async (input: {
|
|
159
162
|
channelId: string;
|
|
@@ -161,7 +164,7 @@ export function useSendChatMessage() {
|
|
|
161
164
|
attachments?: ChatAttachment[];
|
|
162
165
|
parentMessageId?: string;
|
|
163
166
|
}) => {
|
|
164
|
-
const res = await client.post("/public/chat/messages", { profileId, ...input });
|
|
167
|
+
const res = await client.post("/public/chat/messages", { profileId, communityId, ...input });
|
|
165
168
|
return res.data as ChatMessage;
|
|
166
169
|
},
|
|
167
170
|
});
|
|
@@ -169,69 +172,69 @@ export function useSendChatMessage() {
|
|
|
169
172
|
|
|
170
173
|
/** Paginated replies for a thread parent (GET /public/chat/messages/:id/replies), oldest first. */
|
|
171
174
|
export function useChatMessageReplies(parentMessageId?: string, limit = 50) {
|
|
172
|
-
const { client, profileId } =
|
|
175
|
+
const { client, profileId, communityId } = useCommunityScope("useChatMessageReplies()");
|
|
173
176
|
return useInfiniteQuery<ChatMessagesPage>({
|
|
174
177
|
queryKey: chatKeys.replies(parentMessageId),
|
|
175
178
|
queryFn: async ({ pageParam = 1 }) => {
|
|
176
179
|
const res = await client.get(`/public/chat/messages/${parentMessageId}/replies`, {
|
|
177
|
-
params: { profileId, page: pageParam, limit },
|
|
180
|
+
params: { profileId, communityId, page: pageParam, limit },
|
|
178
181
|
});
|
|
179
182
|
return res.data;
|
|
180
183
|
},
|
|
181
184
|
initialPageParam: 1,
|
|
182
185
|
getNextPageParam: (lastPage) =>
|
|
183
186
|
lastPage.page * lastPage.limit < lastPage.total ? lastPage.page + 1 : undefined,
|
|
184
|
-
enabled: !!parentMessageId && !!client && !!profileId,
|
|
187
|
+
enabled: !!parentMessageId && !!client && !!profileId && !!communityId,
|
|
185
188
|
});
|
|
186
189
|
}
|
|
187
190
|
|
|
188
191
|
/** Same-tier members the current member may start a DM with (GET /public/chat/dm/directory). */
|
|
189
192
|
export function useChatMemberDirectory() {
|
|
190
|
-
const { client, profileId } =
|
|
193
|
+
const { client, profileId, communityId } = useCommunityScope("useChatMemberDirectory()");
|
|
191
194
|
return useQuery<ChatDirectoryMember[]>({
|
|
192
|
-
queryKey: chatKeys.memberDirectory(
|
|
195
|
+
queryKey: chatKeys.memberDirectory(communityId),
|
|
193
196
|
queryFn: async () => {
|
|
194
|
-
const res = await client.get("/public/chat/dm/directory", { params: { profileId } });
|
|
197
|
+
const res = await client.get("/public/chat/dm/directory", { params: { profileId, communityId } });
|
|
195
198
|
return res.data;
|
|
196
199
|
},
|
|
197
|
-
enabled: !!profileId && !!client,
|
|
200
|
+
enabled: !!profileId && !!communityId && !!client,
|
|
198
201
|
});
|
|
199
202
|
}
|
|
200
203
|
|
|
201
204
|
/** Open/create a DM with another member (POST /public/chat/dm/member). */
|
|
202
205
|
export function useCreateMemberDm() {
|
|
203
|
-
const { client, profileId } =
|
|
206
|
+
const { client, profileId, communityId } = useCommunityScope("useCreateMemberDm()");
|
|
204
207
|
const queryClient = useQueryClient();
|
|
205
208
|
return useMutation({
|
|
206
209
|
mutationFn: async (targetAccountId: string) => {
|
|
207
|
-
const res = await client.post("/public/chat/dm/member", { profileId, targetAccountId });
|
|
210
|
+
const res = await client.post("/public/chat/dm/member", { profileId, communityId, targetAccountId });
|
|
208
211
|
return res.data as ChatChannel;
|
|
209
212
|
},
|
|
210
213
|
onSuccess: () => {
|
|
211
|
-
queryClient.invalidateQueries({ queryKey: chatKeys.channels(
|
|
214
|
+
queryClient.invalidateQueries({ queryKey: chatKeys.channels(communityId) });
|
|
212
215
|
},
|
|
213
216
|
});
|
|
214
217
|
}
|
|
215
218
|
|
|
216
219
|
/** The current member's DM channel, if any (GET /public/chat/dm). */
|
|
217
220
|
export function useChatDm() {
|
|
218
|
-
const { client, profileId } =
|
|
221
|
+
const { client, profileId, communityId } = useCommunityScope("useChatDm()");
|
|
219
222
|
return useQuery<ChatChannel | null>({
|
|
220
|
-
queryKey: chatKeys.dm(
|
|
223
|
+
queryKey: chatKeys.dm(communityId),
|
|
221
224
|
queryFn: async () => {
|
|
222
|
-
const res = await client.get("/public/chat/dm", { params: { profileId } });
|
|
225
|
+
const res = await client.get("/public/chat/dm", { params: { profileId, communityId } });
|
|
223
226
|
return res.data;
|
|
224
227
|
},
|
|
225
|
-
enabled: !!profileId && !!client,
|
|
228
|
+
enabled: !!profileId && !!communityId && !!client,
|
|
226
229
|
});
|
|
227
230
|
}
|
|
228
231
|
|
|
229
232
|
/** Open/create the member's DM channel (POST /public/chat/dm). */
|
|
230
233
|
export function useCreateChatDm() {
|
|
231
|
-
const { client, profileId } =
|
|
234
|
+
const { client, profileId, communityId } = useCommunityScope("useCreateChatDm()");
|
|
232
235
|
return useMutation({
|
|
233
236
|
mutationFn: async () => {
|
|
234
|
-
const res = await client.post("/public/chat/dm", { profileId });
|
|
237
|
+
const res = await client.post("/public/chat/dm", { profileId, communityId });
|
|
235
238
|
return res.data as ChatChannel;
|
|
236
239
|
},
|
|
237
240
|
});
|
|
@@ -259,7 +262,7 @@ export function useChatSocket(enabled: boolean) {
|
|
|
259
262
|
const next = io(getForgeWebSocketUrl(apiUrl), { auth: { token }, transports: ["websocket"] });
|
|
260
263
|
socketRef.current = next;
|
|
261
264
|
setSocket(next);
|
|
262
|
-
// Intentionally not disconnecting on unmount
|
|
265
|
+
// Intentionally not disconnecting on unmount: mirrors the client app, where the
|
|
263
266
|
// chat socket persists across channel selection. Callers manage room join/leave.
|
|
264
267
|
}, [enabled, apiUrl]);
|
|
265
268
|
|