Skip to content

Commit ba972a9

Browse files
brand functionality enhancements (#125)
1 parent 3049b00 commit ba972a9

22 files changed

Lines changed: 911 additions & 20 deletions

File tree

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ xcuserdata
1919
DerivedData
2020
*.hmap
2121
*.xcuserstate
22+
.worktrees
23+
.worktree
2224

2325
# Android/IntelliJ
2426
#

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: 72 additions & 7 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 {
@@ -9,6 +15,8 @@ import {
915
import {DISCOVER_FEED_URI, VIDEO_FEED_URI} from '#/lib/constants'
1016
import {DEFAULT_BLUE_HUE} from '#/alf/util/colorGeneration'
1117

18+
const BLACKSKY_MODERATION_DID = 'did:plc:d2mkddsbmnrgr3domzg5qexf'
19+
1220
// Blacksky defaults used as the dev-mode fallback when no brand config is
1321
// injected by bskyweb (i.e. when running `yarn web` locally).
1422
const BLACKSKY_CONFIG: RawCommunityConfig = {
@@ -39,6 +47,7 @@ const BLACKSKY_CONFIG: RawCommunityConfig = {
3947
pds: {
4048
url: 'https://blacksky.app',
4149
},
50+
moderation: [BLACKSKY_MODERATION_DID],
4251
},
4352
feeds: {
4453
defaultPinned: [
@@ -103,15 +112,48 @@ const BLACKSKY_PRIMARY_SCALE = {
103112
primary_975: '#13133B',
104113
}
105114

115+
type Pin = {type: string; value: string; pinned: boolean}
116+
117+
// The Following timeline is a protocol-level home invariant, not per-community
118+
// configurable data. Guarantee exactly one entry (appended if missing) and drop
119+
// any following entry mis-typed as a feed. Backstops a served config that omits
120+
// Following (stale cache, service-down fallback, or upstream serialization bug).
121+
export function ensureFollowingPinned(pins: Pin[]): Pin[] {
122+
let hasFollowing = false
123+
const out = pins.filter(p => {
124+
if (p.type === 'feed' && p.value === 'following') return false // drop corruption
125+
if (p.type === 'timeline' && p.value === 'following') {
126+
if (hasFollowing) return false // dedupe
127+
hasFollowing = true
128+
}
129+
return true
130+
})
131+
if (!hasFollowing)
132+
out.push({type: 'timeline', value: 'following', pinned: true})
133+
return out
134+
}
135+
136+
function normalizeBrandConfig(
137+
config: ComputedBrandConfig,
138+
): ComputedBrandConfig {
139+
return {
140+
...config,
141+
feeds: {
142+
...config.feeds,
143+
defaultPinned: ensureFollowingPinned(config.feeds?.defaultPinned ?? []),
144+
},
145+
}
146+
}
147+
106148
// In multi-brand mode, bskyweb injects the config into the HTML before React loads.
107149
// In dev mode (yarn web), fall back to the Blacksky defaults above.
108150
function resolveBrandConfig(): ComputedBrandConfig {
109151
if (typeof window !== 'undefined' && window.__BRAND_CONFIG__) {
110-
return window.__BRAND_CONFIG__
152+
return normalizeBrandConfig(window.__BRAND_CONFIG__)
111153
}
112154

113155
const config = generateComputedConfig(BLACKSKY_CONFIG)
114-
return {
156+
return normalizeBrandConfig({
115157
...config,
116158
theme: {
117159
...config.theme,
@@ -125,7 +167,7 @@ function resolveBrandConfig(): ComputedBrandConfig {
125167
selectionDark: '#464985',
126168
},
127169
},
128-
}
170+
})
129171
}
130172

131173
export const DEFAULT_BRAND_CONFIG = resolveBrandConfig()
@@ -181,18 +223,41 @@ if (typeof document !== 'undefined') {
181223
}
182224

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

185230
export function BrandProvider({
186231
children,
187232
config,
188233
}: 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+
}, [])
189245
return (
190-
<BrandContext.Provider value={config || DEFAULT_BRAND_CONFIG}>
191-
{children}
192-
</BrandContext.Provider>
246+
<SetBrandContext.Provider value={setBrandConfig}>
247+
<BrandContext.Provider value={current}>{children}</BrandContext.Provider>
248+
</SetBrandContext.Provider>
193249
)
194250
}
195251

196252
export function useBrand(): ComputedBrandConfig {
197253
return useContext(BrandContext)
198254
}
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+
}

src/lib/community/__tests__/BrandContext.test.ts

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
import {describe, expect, it} from '@jest/globals'
22

33
import {DISCOVER_FEED_URI, VIDEO_FEED_URI} from '#/lib/constants'
4-
import {BRAND_DOMAIN, DEFAULT_BRAND_CONFIG} from '../BrandContext'
4+
import {
5+
BRAND_DOMAIN,
6+
DEFAULT_BRAND_CONFIG,
7+
ensureFollowingPinned,
8+
} from '../BrandContext'
59

610
describe('BrandContext defaults', () => {
711
it('preserves Blacksky defaults when no brand config is injected', () => {
@@ -22,6 +26,9 @@ describe('BrandContext defaults', () => {
2226
expect(DEFAULT_BRAND_CONFIG.messages.postButtonLabel).toBe('Post')
2327

2428
expect(DEFAULT_BRAND_CONFIG.services.pds.url).toBe('https://blacksky.app')
29+
expect(DEFAULT_BRAND_CONFIG.services.moderation).toEqual([
30+
'did:plc:d2mkddsbmnrgr3domzg5qexf',
31+
])
2532
expect(DEFAULT_BRAND_CONFIG.web.domains.main).toBe(
2633
'https://blacksky.community',
2734
)
@@ -70,3 +77,39 @@ describe('BrandContext defaults', () => {
7077
expect(DEFAULT_BRAND_CONFIG.theme.css.selectionDark).toBe('#464985')
7178
})
7279
})
80+
81+
describe('ensureFollowingPinned', () => {
82+
it('appends Following when missing, preserving order', () => {
83+
const out = ensureFollowingPinned([
84+
{type: 'feed', value: 'at://x/medsky', pinned: true},
85+
])
86+
expect(out[0]).toEqual({type: 'feed', value: 'at://x/medsky', pinned: true})
87+
expect(out[out.length - 1]).toEqual({
88+
type: 'timeline',
89+
value: 'following',
90+
pinned: true,
91+
})
92+
})
93+
94+
it('does not duplicate an existing Following entry', () => {
95+
const out = ensureFollowingPinned([
96+
{type: 'timeline', value: 'following', pinned: true},
97+
{type: 'feed', value: 'at://x/medsky', pinned: true},
98+
])
99+
expect(
100+
out.filter(f => f.type === 'timeline' && f.value === 'following'),
101+
).toHaveLength(1)
102+
})
103+
104+
it('strips a malformed feed-typed following entry', () => {
105+
const out = ensureFollowingPinned([
106+
{type: 'feed', value: 'following', pinned: true},
107+
])
108+
expect(out.some(f => f.type === 'feed' && f.value === 'following')).toBe(
109+
false,
110+
)
111+
expect(
112+
out.filter(f => f.type === 'timeline' && f.value === 'following'),
113+
).toHaveLength(1)
114+
})
115+
})

src/lib/community/__tests__/configGenerator.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -423,6 +423,24 @@ describe('generateComputedConfig', () => {
423423
expect(config.services.pds.availableHandles).toEqual([])
424424
})
425425

426+
it('passes through moderation service DIDs from raw config', () => {
427+
const moderation = ['did:plc:mod1', 'did:plc:mod2']
428+
const withModeration: RawCommunityConfig = {
429+
...theinviteConfig,
430+
services: {
431+
...theinviteConfig.services,
432+
moderation,
433+
},
434+
}
435+
const config = generateComputedConfig(withModeration)
436+
expect(config.services.moderation).toEqual(moderation)
437+
})
438+
439+
it('defaults moderation service DIDs to empty array when omitted', () => {
440+
const config = generateComputedConfig(theinviteConfig)
441+
expect(config.services.moderation).toEqual([])
442+
})
443+
426444
it('produces default onboarding config when omitted', () => {
427445
const config = generateComputedConfig(theinviteConfig)
428446
expect(config.onboarding.starterPack).toBe('')
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/configGenerator.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -387,6 +387,7 @@ export function generateComputedConfig(
387387
url: raw.services.pds.url,
388388
availableHandles: raw.services.pds.availableHandles || [],
389389
},
390+
moderation: raw.services.moderation || [],
390391
},
391392
web: {
392393
themeColor: raw.web?.themeColor || primary,

0 commit comments

Comments
 (0)