Skip to content

Commit eede9d1

Browse files
feat(community): native brand follows the active account
On native there is no hostname signal (unlike web, where bskyweb injects the brand by Host), so the app now resolves a community brand from the active account and re-themes live on account switch: - resolveBrand: resolve by stamped communitySlug → Latinsky handle carveout → PDS host (GET /brands/resolve). The Blacksky PDS is the default/shared PDS and is never resolved by PDS (its config is bundled); the host is derived from the default brand config. - BrandProvider holds config in state + exposes a setter; ALF re-themes via its existing brand props, no remount. - useCommunityBrandSync (native) seeds from a per-account MMKV cache (no flash), reconciles against the brand service, and resets to the bundled Blacksky brand on logged-out surfaces (incl. "add another account" mid-session). - Signup: a "Community" step with Blacksky as the default option and other published communities in a dropdown; the choice sets the PDS and stamps the community slug on the created account. Web behavior is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 56e1360 commit eede9d1

14 files changed

Lines changed: 552 additions & 14 deletions

File tree

src/App.native.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {useLingui} from '@lingui/react/macro'
1414
import * as Sentry from '@sentry/react-native'
1515

1616
import {BrandProvider, useBrand} from '#/lib/community/BrandContext'
17+
import {CommunityBrandSync} from '#/lib/community/useCommunityBrandSync'
1718
import {Provider as HideBottomBarBorderProvider} from '#/lib/hooks/useHideBottomBarBorder'
1819
import {QueryProvider} from '#/lib/react-query'
1920
import {ThemeProvider} from '#/lib/ThemeContext'
@@ -155,6 +156,7 @@ function InnerApp() {
155156
<LabelDefsProvider>
156157
<ModerationOptsProvider>
157158
<LoggedOutViewProvider>
159+
<CommunityBrandSync />
158160
<SelectedFeedProvider>
159161
<HiddenRepliesProvider>
160162
<HomeBadgeProvider>

src/env/common.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,16 @@ export const LIVE_EVENTS_URL = IS_DEV
155155
? (LIVE_EVENTS_DEV_URL ?? LIVE_EVENTS_PROD_URL)
156156
: LIVE_EVENTS_PROD_URL
157157

158+
/**
159+
* Base URL of the Acorn brand service. Used on native to resolve a community's
160+
* brand config from the active account's PDS (`/brands/resolve?pds=` / `?slug=`).
161+
* On web the config is injected by bskyweb (`window.__BRAND_CONFIG__`), so this
162+
* is only consulted on native.
163+
*/
164+
export const BRAND_SERVICE_URL: string =
165+
process.env.EXPO_PUBLIC_BRAND_SERVICE_URL ||
166+
'https://brand.acorn.blacksky.community'
167+
158168
/**
159169
* Stripe publishable key for embedded checkout
160170
*/

src/lib/community/BrandContext.tsx

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
1-
import {createContext, type PropsWithChildren, useContext} from 'react'
1+
import {
2+
createContext,
3+
type PropsWithChildren,
4+
useCallback,
5+
useContext,
6+
useState,
7+
} from 'react'
28

39
import {generateComputedConfig} from '#/lib/community/configGenerator'
410
import {
@@ -122,11 +128,14 @@ export function ensureFollowingPinned(pins: Pin[]): Pin[] {
122128
}
123129
return true
124130
})
125-
if (!hasFollowing) out.push({type: 'timeline', value: 'following', pinned: true})
131+
if (!hasFollowing)
132+
out.push({type: 'timeline', value: 'following', pinned: true})
126133
return out
127134
}
128135

129-
function normalizeBrandConfig(config: ComputedBrandConfig): ComputedBrandConfig {
136+
function normalizeBrandConfig(
137+
config: ComputedBrandConfig,
138+
): ComputedBrandConfig {
130139
return {
131140
...config,
132141
feeds: {
@@ -214,18 +223,41 @@ if (typeof document !== 'undefined') {
214223
}
215224

216225
const BrandContext = createContext<ComputedBrandConfig>(DEFAULT_BRAND_CONFIG)
226+
const SetBrandContext = createContext<(config: ComputedBrandConfig) => void>(
227+
() => {},
228+
)
217229

218230
export function BrandProvider({
219231
children,
220232
config,
221233
}: PropsWithChildren<{config?: ComputedBrandConfig}>) {
234+
// On web the active config is fixed at page load (bskyweb injects it by
235+
// hostname), so this state never changes. On native it starts at the bundled
236+
// Blacksky default and is updated to follow the active account's community —
237+
// see useCommunityBrandSync. Account switches don't remount this provider
238+
// (it sits above the account-keyed subtree), so re-theming rides this state.
239+
const [current, setCurrent] = useState<ComputedBrandConfig>(
240+
config || DEFAULT_BRAND_CONFIG,
241+
)
242+
const setBrandConfig = useCallback((next: ComputedBrandConfig) => {
243+
setCurrent(normalizeBrandConfig(next))
244+
}, [])
222245
return (
223-
<BrandContext.Provider value={config || DEFAULT_BRAND_CONFIG}>
224-
{children}
225-
</BrandContext.Provider>
246+
<SetBrandContext.Provider value={setBrandConfig}>
247+
<BrandContext.Provider value={current}>{children}</BrandContext.Provider>
248+
</SetBrandContext.Provider>
226249
)
227250
}
228251

229252
export function useBrand(): ComputedBrandConfig {
230253
return useContext(BrandContext)
231254
}
255+
256+
/**
257+
* Setter for the active brand config. Used by useCommunityBrandSync (native) to
258+
* re-theme when the active account resolves to a different community. The value
259+
* is normalized (following-pinned) before it is applied.
260+
*/
261+
export function useSetBrandConfig(): (config: ComputedBrandConfig) => void {
262+
return useContext(SetBrandContext)
263+
}
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import {afterEach, beforeEach, describe, expect, it, jest} from '@jest/globals'
2+
3+
import {
4+
fetchBrandBySlug,
5+
normalizeHost,
6+
resolveBrandForAccount,
7+
resolveBrandForPds,
8+
} from '../resolveBrand'
9+
10+
describe('normalizeHost', () => {
11+
it('strips scheme, port, path, and lowercases', () => {
12+
expect(normalizeHost('https://Blacksky.app')).toBe('blacksky.app')
13+
expect(normalizeHost('blacksky.app')).toBe('blacksky.app')
14+
expect(normalizeHost('https://blacksky.app:443/xrpc')).toBe('blacksky.app')
15+
expect(normalizeHost('http://pds.example.com/')).toBe('pds.example.com')
16+
expect(normalizeHost(' https://blacksky.app/ ')).toBe('blacksky.app')
17+
expect(normalizeHost('blacksky.app.')).toBe('blacksky.app')
18+
})
19+
})
20+
21+
describe('brand resolution', () => {
22+
const makeResponse = (body: unknown, ok = true): Response =>
23+
({ok, json: () => Promise.resolve(body)}) as unknown as Response
24+
let fetchMock: jest.MockedFunction<typeof fetch>
25+
26+
beforeEach(() => {
27+
fetchMock = jest
28+
.fn<typeof fetch>()
29+
.mockResolvedValue(
30+
makeResponse({slug: 'x', status: 'published', computed: {slug: 'x'}}),
31+
)
32+
global.fetch = fetchMock
33+
})
34+
afterEach(() => {
35+
jest.restoreAllMocks()
36+
})
37+
38+
function calledUrl(): string {
39+
const arg = fetchMock.mock.calls[0]?.[0]
40+
return typeof arg === 'string' ? arg : ''
41+
}
42+
43+
it('fetchBrandBySlug hits ?slug=', async () => {
44+
await fetchBrandBySlug('latinsky')
45+
expect(calledUrl()).toContain('/brands/resolve?slug=latinsky')
46+
})
47+
48+
it('resolveBrandForPds normalizes the host into ?pds=', async () => {
49+
await resolveBrandForPds('https://blacksky.app:443/')
50+
expect(calledUrl()).toContain('/brands/resolve?pds=blacksky.app')
51+
})
52+
53+
it('returns null (falls back to default) on non-ok responses', async () => {
54+
fetchMock.mockResolvedValueOnce(makeResponse({}, false))
55+
expect(await resolveBrandForPds('https://blacksky.app')).toBeNull()
56+
})
57+
58+
it('returns null when fetch throws', async () => {
59+
fetchMock.mockRejectedValueOnce(new Error('offline'))
60+
expect(await resolveBrandForPds('https://blacksky.app')).toBeNull()
61+
})
62+
63+
describe('resolveBrandForAccount precedence', () => {
64+
it('1. stamped communitySlug wins over everything', async () => {
65+
await resolveBrandForAccount({
66+
communitySlug: 'medsky',
67+
pdsUrl: 'https://blacksky.app',
68+
handle: 'a.latinsky.app',
69+
})
70+
expect(calledUrl()).toContain('slug=medsky')
71+
})
72+
73+
it('2. Latinsky carveout: Blacksky PDS + latinsky handle → latinsky slug', async () => {
74+
await resolveBrandForAccount({
75+
pdsUrl: 'https://blacksky.app',
76+
handle: 'alice.latinsky.app',
77+
})
78+
expect(calledUrl()).toContain('slug=latinsky')
79+
})
80+
81+
it('2b. afrolatinsky handle also triggers the carveout', async () => {
82+
await resolveBrandForAccount({
83+
pdsUrl: 'https://blacksky.app',
84+
handle: 'bob.afrolatinsky.app',
85+
})
86+
expect(calledUrl()).toContain('slug=latinsky')
87+
})
88+
89+
it('3. plain Blacksky account → null (bundled default), never PDS-resolved', async () => {
90+
const result = await resolveBrandForAccount({
91+
pdsUrl: 'https://blacksky.app',
92+
handle: 'alice.blacksky.app',
93+
})
94+
expect(result).toBeNull()
95+
expect(fetchMock).not.toHaveBeenCalled()
96+
})
97+
98+
it('3b. a latinsky-looking handle on a different PDS is NOT the carveout', async () => {
99+
await resolveBrandForAccount({
100+
pdsUrl: 'https://pds.example.com',
101+
handle: 'evil.latinsky.app',
102+
})
103+
expect(calledUrl()).toContain('pds=pds.example.com')
104+
})
105+
106+
it('returns null when there is no pdsUrl and no slug', async () => {
107+
expect(
108+
await resolveBrandForAccount({handle: 'a.blacksky.app'}),
109+
).toBeNull()
110+
expect(fetchMock).not.toHaveBeenCalled()
111+
})
112+
})
113+
})

src/lib/community/resolveBrand.ts

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import {DEFAULT_BRAND_CONFIG} from '#/lib/community/BrandContext'
2+
import {type ComputedBrandConfig} from '#/lib/community/types'
3+
import {LATINSKY_BRAND_SLUG, LATINSKY_HANDLE_SUFFIXES} from '#/lib/constants'
4+
import {BRAND_SERVICE_URL} from '#/env'
5+
6+
/**
7+
* Minimal shape of a stored account needed to resolve its community brand.
8+
* Kept local (rather than importing SessionAccount) so this module stays a pure,
9+
* testable leaf with no session dependency.
10+
*/
11+
export interface ResolvableAccount {
12+
/** The account's PDS URL, e.g. `https://blacksky.app`. */
13+
pdsUrl?: string
14+
/** The account's handle, e.g. `alice.latinsky.app`. */
15+
handle?: string
16+
/** Community slug stamped on the account at signup (deterministic fast path). */
17+
communitySlug?: string
18+
}
19+
20+
/**
21+
* Normalize a host or URL for equality comparison: lowercase, drop the scheme,
22+
* any path/query, the port, and a trailing dot. Mirrors the server-side
23+
* `normalize_host` in acorn/brand so both sides agree on what "same host" means.
24+
*/
25+
export function normalizeHost(input: string): string {
26+
let s = input.trim()
27+
const scheme = s.indexOf('://')
28+
if (scheme !== -1) s = s.slice(scheme + 3)
29+
s = s.split('/')[0]
30+
s = s.split('?')[0]
31+
s = s.split(':')[0]
32+
return s.replace(/\.+$/, '').toLowerCase()
33+
}
34+
35+
async function fetchResolvedConfig(
36+
query: string,
37+
): Promise<ComputedBrandConfig | null> {
38+
try {
39+
const res = await fetch(`${BRAND_SERVICE_URL}/brands/resolve?${query}`, {
40+
headers: {accept: 'application/json'},
41+
})
42+
if (!res.ok) return null
43+
const data = (await res.json()) as {computed?: ComputedBrandConfig}
44+
return data.computed ?? null
45+
} catch {
46+
return null
47+
}
48+
}
49+
50+
/** Summary of a published brand, as returned by the brand service `GET /brands`. */
51+
export interface BrandListEntry {
52+
slug: string
53+
name: string
54+
displayName: string
55+
themeColor: string
56+
logo: string
57+
description: string
58+
/** The community's PDS URL — signup points at this when the community is picked. */
59+
pds: string
60+
}
61+
62+
/**
63+
* List published communities (for the signup community picker). Returns an empty
64+
* array when the service is unreachable so callers can fall back to a
65+
* Blacksky-only default.
66+
*/
67+
export async function fetchBrandList(): Promise<BrandListEntry[]> {
68+
try {
69+
const res = await fetch(`${BRAND_SERVICE_URL}/brands`, {
70+
headers: {accept: 'application/json'},
71+
})
72+
if (!res.ok) return []
73+
const data = (await res.json()) as {brands?: BrandListEntry[]}
74+
return data.brands ?? []
75+
} catch {
76+
return []
77+
}
78+
}
79+
80+
/** Resolve a brand's computed config by community slug. */
81+
export function fetchBrandBySlug(
82+
slug: string,
83+
): Promise<ComputedBrandConfig | null> {
84+
return fetchResolvedConfig(`slug=${encodeURIComponent(slug)}`)
85+
}
86+
87+
/** Resolve a brand's computed config by the account's PDS host. */
88+
export function resolveBrandForPds(
89+
pdsUrl: string,
90+
): Promise<ComputedBrandConfig | null> {
91+
return fetchResolvedConfig(`pds=${encodeURIComponent(normalizeHost(pdsUrl))}`)
92+
}
93+
94+
// The default community (Blacksky) owns/shares this PDS and its config is bundled
95+
// into the app, so the host is taken from the default brand config rather than a
96+
// separate constant — single source of truth for "the default community's PDS".
97+
function isBlackskyPds(pdsUrl: string | undefined): boolean {
98+
if (!pdsUrl) return false
99+
return (
100+
normalizeHost(pdsUrl) ===
101+
normalizeHost(DEFAULT_BRAND_CONFIG.services.pds.url)
102+
)
103+
}
104+
105+
function isLatinskyHandle(handle: string | undefined): boolean {
106+
if (!handle) return false
107+
const h = handle.toLowerCase()
108+
return LATINSKY_HANDLE_SUFFIXES.some(suffix => h.endsWith(suffix))
109+
}
110+
111+
/**
112+
* Resolve the community brand config for an account. Resolution order:
113+
* 1. `communitySlug` stamped at signup — deterministic, and required to
114+
* disambiguate communities that share a PDS.
115+
* 2. The Blacksky PDS is the default/shared PDS and is never resolved by PDS:
116+
* - a Latinsky handle on it → Latinsky (the handle carveout);
117+
* - anything else → null (the bundled Blacksky default). Blacksky itself
118+
* has no brand-service config by design.
119+
* 3. Any other PDS host → brand (the general case).
120+
* Returns `null` when nothing matches or the service is unreachable; callers
121+
* fall back to the bundled Blacksky default.
122+
*/
123+
export async function resolveBrandForAccount(
124+
account: ResolvableAccount,
125+
): Promise<ComputedBrandConfig | null> {
126+
if (account.communitySlug) {
127+
return fetchBrandBySlug(account.communitySlug)
128+
}
129+
if (isBlackskyPds(account.pdsUrl)) {
130+
// Shared/default PDS — not PDS-resolvable. Latinsky is identified by handle;
131+
// a plain Blacksky account falls through to the bundled default.
132+
if (isLatinskyHandle(account.handle)) {
133+
return fetchBrandBySlug(LATINSKY_BRAND_SLUG)
134+
}
135+
return null
136+
}
137+
if (account.pdsUrl) {
138+
return resolveBrandForPds(account.pdsUrl)
139+
}
140+
return null
141+
}

0 commit comments

Comments
 (0)