Skip to content

feat(placements): add placements content configuration API#7416

Open
DanielSinclair wants to merge 5 commits intodaniel/flags-storefrom
daniel/placements-api
Open

feat(placements): add placements content configuration API#7416
DanielSinclair wants to merge 5 commits intodaniel/flags-storefrom
daniel/placements-api

Conversation

@DanielSinclair
Copy link
Copy Markdown
Contributor

APP-3530, APP-3660

What changed

Introduces a generic placements API for remotely configuring featured content slots across the app. Built generically so future screens beyond Discover can add their own placement types without schema changes — screen-specific derived stores live under src/features/placements/stores/<screen>/.

Schema — each document in the Firestore placements collection (rainbow-me → (default) DB):

placements/{id}
  enabled: boolean
  screen:  "discover"
  order:   number
  items: [{ ref: { source: "hyperliquid" | "polymarket", id: string }, order: number }]
  • usePlacementsStore — fetches enabled placement documents from Firestore, items sorted by order
  • useDiscoverPlacementAvailabilityStore — derived store computing which Discover placement types are active; produces { perps: boolean, predictions: boolean, enabled: boolean } consumed by all downstream Discover stores
  • discover_placements_enabled remote config gates the feature (defaults true)
  • Local experimental flags (via useExperimentalConfigStore) and testnet state also gate individual placement types
  • New dependencies: @react-native-firebase/firestore (npm), Firebase/Firestore pod — run pod install in ios/ after pulling

Screen recordings / screenshots

What to test

  • With Firestore placements collection populated: usePlacementsStore.getState().getPlacement('discover-perps') returns the document with items sorted by order

@DanielSinclair DanielSinclair requested a review from akc2267 April 30, 2026 08:43
@socket-security
Copy link
Copy Markdown

socket-security Bot commented Apr 30, 2026

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Addednpm/​@​react-native-firebase/​firestore@​23.8.8100100909880

View full report

Native dep commit so the rebuild requirement is clearly attributed.
Fetch enabled Firestore placement documents, sort items by order, persist with 1h stale / 2d cache.
Named constants prevent string drift between Firestore documents and client code.
Derives which Discover placement types are enabled from remote config, experimental flags, and testnet state. Sets discover_placements_enabled remote config default to true.
Copy link
Copy Markdown
Contributor Author

DanielSinclair commented Apr 30, 2026

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4eb79840e0

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +34 to +35
testnet: false,
setTestnet: testnet => set({ testnet }),
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Initialize testnet state before deriving availability

This store starts with testnet: false and only updates it in useEffect, so on a real testnet account the first render computes availability as if the user were on mainnet. Any consumer that reads useDiscoverPlacementAvailability during that initial render can briefly enable placements (and trigger dependent fetch/render work) before the effect flips the value, which is a user-visible and behaviorally incorrect transient state.

Useful? React with 👍 / 👎.

.map((d: { id: string; data: () => Record<string, unknown> }) => ({ id: d.id, ...d.data() }) as Placement)
.map((p: Placement) => ({
...p,
items: (p.items ?? []).sort((a: PlacementItem, b: PlacementItem) => a.order - b.order),
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Validate placement items before calling sort

The code assumes p.items is always an array and directly calls .sort after only nullish fallback. Because Firestore documents are schemaless, a malformed items value (e.g. object/string from bad config) will throw TypeError here, causing the whole placements fetch to fail instead of safely skipping/normalizing that document.

Useful? React with 👍 / 👎.

@github-actions
Copy link
Copy Markdown

Launch in simulator or device for 98b6605

@github-actions
Copy link
Copy Markdown

🧪 Flashlight Performance Report (AWS Device Farm)

🔀 Commit: 98b6605

📎 View Artifacts

Metric Current Δ vs Baseline
Time to Interactive (TTI) 5624 ms
Average FPS 56.48
Average RAM 399.7 MB

@DanielSinclair DanielSinclair requested review from maxbbb and removed request for akc2267 April 30, 2026 23:23
items: (p.items ?? []).sort((a: PlacementItem, b: PlacementItem) => a.order - b.order),
}));
} catch (e) {
logger.error(new RainbowError('[placementsStore]: Failed to fetch placements', e as Error), { error: e });
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The second argument (e as Error) doesn't require type casting. Also we should not pass the error object along in the metadata param anymore, it's regularly dropped by Sentry due to its size and is more properly used as the cause (which is the second argument of RainbowError).

All that being said, the query stores handle wrapping these fetches in try catch and logging the errors, so don't think we need this here at all.

getPlacement: (id: string) => get().placements.find(p => p.id === id),
}),
{
partialize: state => ({ placements: state.placements }),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should only define this partialize when there is some portion of the state we're omitting. I believe currently this has the same behavior as not defining it.

Comment on lines +5 to +8
export const PLACEMENT_IDS = {
PERPS: 'discover_featured_perps_carousel',
PREDICTIONS: 'discover_featured_predictions_carousel',
} as const;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we had some other placement of perps on another screen / location what would it's key in this object be? I would assume "perps" and "predictions" are too generic to handle additions cleanly, and the keys would be the aligned with the values in this case.

Comment on lines +33 to +36
const useDiscoverPlacementNetworkStore = createRainbowStore<DiscoverPlacementNetworkState>(set => ({
testnet: false,
setTestnet: testnet => set({ testnet }),
}));
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I understand that supporting the isTestnet logic is maintaining how the perps and predictions cards were previously rendered, but given the extra complexity it adds here I think it's worth interrogating whether it's required at all.

Taking a quick trace through I'm not sure there is any functional reason these need to not be displayed when isTestnet is true.

Comment on lines +48 to +50
const polymarketLocal = $(
useExperimentalConfigStore,
state => IS_INTERNAL && (state.config[POLYMARKET] ?? defaultConfig[POLYMARKET].value)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would be nice if the store exported a helper like getExperimentalFlagValue that did this IS_INTERNAL and default fallback given it will always be required when accessing a value outside of the useExperimentalFlag hook. That hook could use the same helper so that the logic only has one source of truth.

return testnet;
}

export function computeDiscoverPlacementAvailability({
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unless this is used somewhere else in a later PR, shouldn't be exported. Also it's adding misdirection being a separate function vs. just inlined above.

Comment on lines +17 to +22
return snap.docs
.map((d: { id: string; data: () => Record<string, unknown> }) => ({ id: d.id, ...d.data() }) as Placement)
.map((p: Placement) => ({
...p,
items: (p.items ?? []).sort((a: PlacementItem, b: PlacementItem) => a.order - b.order),
}));
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could be a little cleaner with a helper like this:

  function parsePlacementDoc(doc: QueryDocumentSnapshot): Placement {
    const data = doc.data() as Omit<Placement, 'id'>;
    return {
      ...data,
      id: doc.id,
      items: [...(data.items ?? [])].sort((a, b) => a.order - b.order),
    };
  }

Which makes it more clear what it's doing to each doc and lets us only iterate through the docs once.

Comment on lines +34 to +35
const PLACEMENTS_STALE_TIME = time.hours(1);
const PLACEMENTS_CACHE_TIME = time.days(2);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: these would be fine inlined below

},
(_set, get) => ({
placements: [],
getPlacement: (id: string) => get().placements.find(p => p.id === id),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Obviously not consequential given the number of these, but given we'll always be getting these by id, I wonder if it would be better for the main data structure returned by the fetch function to be placementsById rather than an array, so that lookup doesn't require looping the whole thing every time.

Comment on lines +22 to +23
id: string;
screen: string;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could type these more concretely ie. (typeof PLACEMENT_IDS)[keyof typeof PLACEMENT_IDS];

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants