Highlighted Stories
Editorial content surface — Instagram-style mobile stories and desktop article cards used for announcements, feature launches, and promos.
What this is
Highlighted Stories is the editorial surface used for announcements, feature launches, and promos. It has two manifestations:
- Story Modal — mobile-first, Instagram-style. Tap zones to advance, 5-second auto-advance, emoji reactions.
- Highlight Modal — desktop-first, article-reader. Long-form body with sanitised HTML, supports rich blocks (headings, blockquotes, code).
Stories are surfaced from the app home as a horizontal shelf of cards on the welcome screen, plus a dedicated circular “Story” entry that opens the mobile viewer.
Data model
Stories are stored as JSONB in the highlights table. Localised fields are stored as LangMap objects (Record<string, string>) so a single story carries all 7 locales.
type HighlightRecord = {
id: string;
position: number;
active: boolean;
expires: boolean | string; // true = 24h auto-expire from createdAt; ISO timestamp = explicit expiry
tag: string; // 'promo' | 'new_feature' | …
title: LangMap;
description: LangMap;
body: LangMap;
coverImage: LangMap;
mobileAvatar: LangMap;
ctaLabel: LangMap;
ctaLink: string; // 'https://…' or 'app://chat' / 'app://trade' / …
prompt?: string; // optional text to prefill chat input on app://chat CTAs
coverMediaType: 'image' | 'video';
innerMediaType: 'image' | 'video';
};
A separate story_reactions table records (user_id, story_id, reaction_type) with a composite primary key.
Editorial rules
- Maximum 20 highlights in the system at any time. Creation is rejected past the cap.
- Title required. Body and cover are optional but strongly encouraged.
- HTML sanitisation. Body content is run through an allow-list (HIGHLIGHT_HTML_CONFIG) that permits
h1–h6,p,blockquote,code, lists, and basic inline tags. Anything else is stripped. - CTA targets. External CTAs must be
http(s). Internal CTAs use theapp://scheme. Recognised internal targets includeapp://chat,app://trade,app://stake,app://signals,app://agents. - Prompt prefill. A story carrying
promptand a CTA pointing toapp://chatprefills the chat input with the prompt; it does not auto-send. - Position is editorially controlled and re-orderable from the admin tab.
Visibility
A story is “live” when:
active === true, and- If
expiresis an ISO timestamp, that timestamp is in the future; ifexpires === true, less than 24h has passed sincecreatedAt; otherwise no expiry applies.
Live stories are returned by GET /v1/content?lang={locale} and cached stale-while-revalidate at the v1-content:{lang} key for 5 minutes.
Media handling
- Storage. Uploads are routed through ObjectStorageService and persisted at
/api/storage/public-objects/highlights/…. Legacy/api/uploads/paths are auto-migrated on server boot. - Allowed formats. Images: JPG, PNG, GIF, WebP. Videos: MP4, WebM, MOV.
- Safe URL guard.
isSafeMediaUrlOrPathrejects anything that isn’t HTTPS or a verified internal storage path. - Video behaviour. Cover videos autoplay muted in the shelf; the story viewer freezes on the last frame instead of looping.
The editorial surface is staff-controlled. Partners do not currently publish stories directly; integration requests for partner-tagged stories go through the Partner Integration channel.
Public endpoints
GET /v1/content Query: lang. Returns the live story list merged with general app content. Cached 5 minutes per locale.
POST /v1/highlights/{id}/view Increments the view count.
GET /v1/highlights/{id}/reactions Returns reaction counts and the current user’s reactions.
POST /v1/highlights/{id}/reactions DELETE /v1/highlights/{id}/reactions Add or remove a reaction (heart, fire, …).
Admin endpoints
GET /v1/admin/highlights Lists all stories, including inactive and expired.
POST /v1/admin/highlights Create a new story. Validates the 20-story cap and the HTML allow-list.
PUT /v1/admin/highlights/{id} Update fields. LangMap fields are merged, so partial updates are allowed.
PUT /v1/admin/highlights/{id}/toggle PUT /v1/admin/highlights/{id}/reorder DELETE /v1/admin/highlights/{id} Safety, limits, failure modes
- HTML allow-list is strict. Custom embeds and scripts are stripped silently. Author copy in plain HTML or use the AI formatting helper in admin.
- Cache TTL. Updates take up to 5 minutes to surface in the public list because of the per-locale content cache.
- Cap enforcement. Hitting 20 stories blocks new creates; archive or delete older stories first.
- Locale fallback. If a story’s LangMap is missing the requested locale, the platform falls back to the English entry.
See also
- Telegram App — stories also surface inside the Telegram Mini App.
- Partner Integration — partner-tagged content requests.