@tribe-nest/forge 3.59.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.
Files changed (94) hide show
  1. package/package.json +1 -1
  2. package/src/_tests/testCommunity.tsx +28 -0
  3. package/src/contexts/CommunityContext.tsx +192 -0
  4. package/src/data/queries/useChat.ts +42 -39
  5. package/src/data/queries/useCommunities.ts +343 -0
  6. package/src/data/queries/useCommunity.ts +93 -89
  7. package/src/data/queries/useMembership.ts +32 -7
  8. package/src/data/queries/useMembershipCalls.ts +116 -7
  9. package/src/data/queries/usePostCollections.ts +28 -16
  10. package/src/data/queries/usePosts.ts +16 -14
  11. package/src/data/queries/useSubscriptions.ts +22 -8
  12. package/src/i18n/de.json +145 -1
  13. package/src/i18n/en.json +145 -1
  14. package/src/index.ts +19 -7
  15. package/src/runtime/pages.spec.ts +31 -0
  16. package/src/runtime/pages.ts +21 -22
  17. package/src/server/_tests/buildWebManifest.spec.ts +1 -1
  18. package/src/server/_tests/forgeServiceWorkerScript.spec.ts +4 -4
  19. package/src/ui/format/_tests/membershipGate.spec.ts +6 -6
  20. package/src/ui/format/membershipGate.ts +19 -15
  21. package/src/ui/headless/_tests/communityGate.spec.tsx +80 -0
  22. package/src/ui/headless/_tests/communityHarness.tsx +205 -0
  23. package/src/ui/headless/_tests/communityProvider.spec.tsx +151 -0
  24. package/src/ui/headless/_tests/useCommunityInvite.spec.tsx +104 -0
  25. package/src/ui/headless/_tests/useCommunityJoin.spec.tsx +382 -0
  26. package/src/ui/headless/_tests/useCommunityMembership.spec.tsx +151 -0
  27. package/src/ui/headless/_tests/useCommunityReads.spec.tsx +286 -0
  28. package/src/ui/headless/_tests/useCommunityUpgrade.spec.tsx +157 -0
  29. package/src/ui/headless/auth/useSignupForm.ts +10 -2
  30. package/src/ui/headless/communities/CommunityGate.tsx +67 -0
  31. package/src/ui/headless/communities/index.ts +55 -0
  32. package/src/ui/headless/communities/paths.ts +36 -0
  33. package/src/ui/headless/communities/useCommunity.ts +140 -0
  34. package/src/ui/headless/communities/useCommunityAbout.ts +71 -0
  35. package/src/ui/headless/communities/useCommunityHome.ts +88 -0
  36. package/src/ui/headless/communities/useCommunityInvite.ts +116 -0
  37. package/src/ui/headless/communities/useCommunityJoin.ts +444 -0
  38. package/src/ui/headless/communities/useCommunityMembership.ts +136 -0
  39. package/src/ui/headless/communities/useCommunityUpgrade.ts +213 -0
  40. package/src/ui/headless/communities/useMyMemberships.ts +59 -0
  41. package/src/ui/headless/community/useCommunityAttachmentUpload.ts +7 -3
  42. package/src/ui/headless/index.ts +8 -4
  43. package/src/ui/headless/memberHome/index.ts +1 -1
  44. package/src/ui/headless/memberHome/useMemberHomeNav.ts +44 -42
  45. package/src/ui/headless/membership/_tests/membershipCheckoutRefetch.spec.tsx +8 -0
  46. package/src/ui/headless/membership/_tests/useMembershipCheckoutApplication.spec.tsx +5 -4
  47. package/src/ui/headless/membership/_tests/useMembershipCheckoutQuestionnaire.spec.tsx +1 -1
  48. package/src/ui/headless/membership/_tests/useMembershipCheckoutTrial.spec.tsx +1 -1
  49. package/src/ui/headless/membership/useMembershipCheckout.ts +89 -22
  50. package/src/ui/headless/membership/useMembershipGateNotice.ts +25 -7
  51. package/src/ui/headless/posts/upsell.ts +54 -8
  52. package/src/ui/headless/posts/useMemberPostsFeed.ts +6 -5
  53. package/src/ui/headless/posts/usePostCollectionDetail.ts +7 -4
  54. package/src/ui/headless/posts/usePostCollectionsList.ts +6 -5
  55. package/src/ui/headless/posts/usePostItem.ts +6 -5
  56. package/src/ui/index.ts +17 -12
  57. package/src/ui/styled/AccountDashboard.tsx +48 -256
  58. package/src/ui/styled/MembershipCheckout.tsx +37 -17
  59. package/src/ui/styled/MembershipTierCallout.tsx +20 -1
  60. package/src/ui/styled/MembershipTiers.tsx +49 -11
  61. package/src/ui/styled/Paywall.tsx +1 -1
  62. package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +1 -1
  63. package/src/ui/styled/_tests/AccountDashboardMemberships.spec.tsx +151 -0
  64. package/src/ui/styled/_tests/AccountDashboardRentals.spec.tsx +1 -1
  65. package/src/ui/styled/_tests/CourseCallsSection.spec.tsx +7 -1
  66. package/src/ui/styled/_tests/MembershipCheckout.spec.tsx +54 -11
  67. package/src/ui/styled/_tests/membershipTierCallout.spec.tsx +1 -1
  68. package/src/ui/styled/_tests/membershipTiersCuratedAccess.spec.tsx +1 -1
  69. package/src/ui/styled/communities/CommunitiesList.tsx +88 -0
  70. package/src/ui/styled/communities/CommunityAbout.tsx +140 -0
  71. package/src/ui/styled/communities/CommunityHome.tsx +175 -0
  72. package/src/ui/styled/communities/CommunityJoinSheet.tsx +444 -0
  73. package/src/ui/styled/communities/CommunityMembershipMenu.tsx +133 -0
  74. package/src/ui/styled/communities/CommunityPending.tsx +49 -0
  75. package/src/ui/styled/communities/CommunityUpgradeSheet.tsx +137 -0
  76. package/src/ui/styled/communities/MyMembershipsList.tsx +89 -0
  77. package/src/ui/styled/communities/_tests/CommunityPages.spec.tsx +102 -0
  78. package/src/ui/styled/communities/index.ts +10 -0
  79. package/src/ui/styled/communities/shared.tsx +149 -0
  80. package/src/ui/styled/community/_tests/CommunityComposer.spec.tsx +4 -1
  81. package/src/ui/styled/community/_tests/CommunityPostDetail.spec.tsx +4 -1
  82. package/src/ui/styled/forge-utilities.css +19 -0
  83. package/src/ui/styled/members/CallTranscriptViewer.tsx +424 -0
  84. package/src/ui/styled/members/MemberCallsCalendar.tsx +142 -10
  85. package/src/ui/styled/members/MemberHomeLayout.tsx +35 -17
  86. package/src/ui/styled/members/MemberPostCard.tsx +9 -5
  87. package/src/ui/styled/members/PostCollectionDetail.tsx +18 -10
  88. package/src/ui/styled/members/PostCollectionsGrid.tsx +24 -10
  89. package/src/ui/styled/members/_tests/MemberCallsCalendar.spec.tsx +167 -8
  90. package/src/ui/styled/members/callTranscriptText.ts +67 -0
  91. package/src/utils/_tests/safeRedirect.spec.ts +2 -2
  92. package/src/ui/headless/membership/MembershipGate.tsx +0 -44
  93. package/src/ui/styled/_tests/AccountDashboardCommunity.spec.tsx +0 -149
  94. package/src/ui/styled/_tests/AccountDashboardTrial.spec.tsx +0 -105
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tribe-nest/forge",
3
- "version": "3.59.0",
3
+ "version": "3.60.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -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
- channels: (profileId?: string) => ["public-chat-channels", profileId] as const,
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: (profileId?: string) => ["public-chat-unread-counts", profileId] as const,
74
- dm: (profileId?: string) => ["public-chat-dm", profileId] as const,
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: (profileId?: string) => ["public-chat-member-directory", profileId] as const,
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 } = useForge();
86
+ const { client, profileId, communityId } = useCommunityScope("useChatChannels()");
84
87
  return useQuery<ChatChannel[]>({
85
- queryKey: chatKeys.channels(profileId),
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 } = useForge();
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 } = useForge();
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 } = useForge();
130
+ const { client, profileId, communityId } = useCommunityScope("useChatUnreadCounts()");
128
131
  return useQuery<ChatUnreadCount[]>({
129
- queryKey: chatKeys.unread(profileId),
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 } = useForge();
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 } = useForge();
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 } = useForge();
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 } = useForge();
193
+ const { client, profileId, communityId } = useCommunityScope("useChatMemberDirectory()");
191
194
  return useQuery<ChatDirectoryMember[]>({
192
- queryKey: chatKeys.memberDirectory(profileId),
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 } = useForge();
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(profileId) });
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 } = useForge();
221
+ const { client, profileId, communityId } = useCommunityScope("useChatDm()");
219
222
  return useQuery<ChatChannel | null>({
220
- queryKey: chatKeys.dm(profileId),
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 } = useForge();
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 — mirrors the client app, where the
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