Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions docs/ui-interaction-and-motion.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,22 @@ information should recede without becoming illegible.
- Keep copy direct and operational. Lead with the state or object, then the
explanation and next action.

News rows in `ui/src/pages/NewsPage.tsx` form a local-calendar-day timeline.
Time stays in the left gutter. The headline is a separate, prominent block above
the summary, which previews up to three lines. Editorial images align with the
headline on the right, with the source below; narrow screens place this image
and source beneath the text. Bordered market/topic labels and a compact
disclosure arrow follow the summary; expanding never hides the image. Flags
identify the supplied market classification, not a company's domicile or the
country mentioned in a headline. The local flag assets retain their license
under `ui/public/market/flags/`; unknown tags remain text. The current news
contract has no structured related-company identity or company-logo field.

The news scroller reveals already-fetched results in batches of 40 when its
bottom sentinel approaches the viewport. Appending keeps the reading position;
changing a filter resets the batch and scroll position. This is not server
pagination: the existing query limit and refresh cadence remain unchanged.

The stable page hierarchy is:

1. global shell and activity rail;
Expand Down Expand Up @@ -404,6 +420,35 @@ drill-in in a fresh copy of the same shell or mask the resulting remount with a
transition. Session terminals and other heavy page content remain active-only
unless their own lifecycle explicitly requires otherwise.

Market's quotes, news, boards, rotation and instrument views share the
`market` shell. News contributes content only; its grouped categories live
inside MarketSidebar and use SidebarRow rather than feature-colored buttons.
Category and news-view selection use separate URL parameters, so selecting an
importance or sentiment view does not discard the current category. Existing
source-tag matching is unchanged by this navigation and theme integration.
News category/view selections are adopted into one news tab and projected back
to the URL, including on a fresh load. Changing a selection does not create
another tab. Narrow screens use the same news directory in the Market drawer.
News initially mounts forty stories and reveals more on demand. Selection
changes reset the visible batch; full story text remains expandable. Polling
waits for an outstanding request instead of repeatedly replacing a slow load,
while explicit refresh, query changes and unmount cancel superseded requests.

Market directory headings (News, Markets, Macro, Watchlist) are static captions.
Parent headings use the shared hierarchy variant (13px, medium weight), and
each child navigation level adds a 12px inset. Clickable categories and leaf
rows share 13px regular text and foreground color; gray is reserved for
secondary summaries and chevrons. Only News category groups disclose children,
with trailing chevrons. Only destination rows receive page selection
styling; a collapsed category group shows the selected category as a plain
summary. Restoring a News selection reveals its group, while users can still
collapse it manually. Search results appear immediately below the search field.
The shared Base UI Collapsible owns keyboard/ARIA and measured panel lifetime;
its 180ms height/opacity transition is disabled with reduced motion. Closing
panels become inert and aria-hidden immediately, including during animation.
The same directory and touch-sized controls serve the narrow-screen drawer.
No new persisted preference is added.

Keyboard focus is not a motion effect. Interactive controls still require a
clear `focus-visible` treatment, meaningful labels, and sensible tab order.

Expand Down
70 changes: 69 additions & 1 deletion src/domain/news/collector/rss-parser.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,8 +127,76 @@ describe('parseRSSXml', () => {
const items = parseRSSXml(xml)
expect(items[0].content).toBe('This is the full article text with much more detail.')
})
})
it('extracts safe RSS image media without adding image to old items', () => {
const xml = `<rss><channel>
<item>
<title>Thumbnail article</title>
<media:thumbnail url="https://cdn.example.com/thumb.jpg"/>
</item>
<item>
<title>Enclosure article</title>
<enclosure url="https://cdn.example.com/article.png" type="image/png"/>
</item>
<item>
<title>HTML article</title>
<description><![CDATA[<p>Summary <img src="https://cdn.example.com/summary.webp"></p>]]></description>
</item>
<item><title>No image</title></item>
</channel></rss>`

const items = parseRSSXml(xml)
expect(items[0].image).toBe('https://cdn.example.com/thumb.jpg')
expect(items[1].image).toBe('https://cdn.example.com/article.png')
expect(items[2].image).toBe('https://cdn.example.com/summary.webp')
expect(items[3]).not.toHaveProperty('image')
})

it('rejects unsafe and non-image media', () => {
const xml = `<rss><channel>
<item>
<title>Unsafe</title>
<media:thumbnail url="data:image/png;base64,abc"/>
<description><![CDATA[<img src="javascript:alert(1)">]]></description>
</item>
<item>
<title>Not an image</title>
<enclosure url="https://cdn.example.com/file.pdf" type="application/pdf"/>
<media:content url="https://cdn.example.com/audio.mp3" medium="audio"/>
</item>
</channel></rss>`

const items = parseRSSXml(xml)
expect(items[0]).not.toHaveProperty('image')
expect(items[1]).not.toHaveProperty('image')
})

it('accepts image media content and ignores non-image variants', () => {
const xml = `<rss><channel>
<item>
<title>Media article</title>
<media:content url="https://cdn.example.com/audio.mp3" medium="audio"/>
<media:content url="https://cdn.example.com/photo.jpg" medium="image"/>
</item>
</channel></rss>`

expect(parseRSSXml(xml)[0].image).toBe('https://cdn.example.com/photo.jpg')
})

it('does not confuse similarly named tags or attributes with the requested values', () => {
const xml = `<rss><channel><itemish><title>Wrong</title></itemish><item>
<title data-title="wrong">Right</title>
<description>Body</description>
<img-src src="https://cdn.example.com/wrong.jpg">
<description><![CDATA[<img data-src="https://cdn.example.com/data.jpg">]]></description>
</item></channel></rss>`

const items = parseRSSXml(xml)
expect(items).toHaveLength(1)
expect(items[0].title).toBe('Right')
expect(items[0].image).toBeUndefined()
})

})
// ==================== fetchAndParseFeed ====================

const MINIMAL_RSS = `<?xml version="1.0"?><rss version="2.0"><channel>
Expand Down
95 changes: 79 additions & 16 deletions src/domain/news/collector/rss-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* News Collector — Zero-dependency RSS / Atom parser
*
* Handles standard RSS 2.0 (<item>) and Atom (<entry>) feeds.
* Extracts: title, description/summary, link, guid/id, pubDate.
* Extracts: title, description/summary, link, guid, pubDate, and safe image URLs.
* Supports CDATA-wrapped content.
*/

Expand All @@ -12,6 +12,8 @@ export interface ParsedFeedItem {
link: string | null
guid: string | null
pubDate: Date | null
/** Safe HTTP(S) image URL when the feed supplied one. */
image?: string
}

/**
Expand Down Expand Up @@ -48,24 +50,24 @@ export function parseRSSXml(xml: string): ParsedFeedItem[] {
let match: RegExpExecArray | null
while ((match = itemRegex.exec(xml)) !== null) {
const block = match[1]
// For title & content: strip HTML first, then decode XML entities.
// This prevents &lt;tag&gt; from being decoded to <tag> then stripped as HTML.
const contentRaw = extractTagRaw(block, 'content:encoded')
?? extractTagRaw(block, 'description')
?? extractTagRaw(block, 'summary')
?? extractTagRaw(block, 'content')
?? ''
const image = extractImage(block, contentRaw)

items.push({
title: cleanText(extractTagRaw(block, 'title') ?? ''),
content: cleanText(
extractTagRaw(block, 'content:encoded')
?? extractTagRaw(block, 'description')
?? extractTagRaw(block, 'summary')
?? extractTagRaw(block, 'content')
?? '',
),
content: cleanText(contentRaw),
link: extractTag(block, 'link') ?? extractAttr(block, 'link', 'href'),
guid: extractTag(block, 'guid') ?? extractTag(block, 'id'),
pubDate: parseDate(
extractTag(block, 'pubDate')
?? extractTag(block, 'published')
?? extractTag(block, 'updated'),
),
...(image ? { image } : {}),
})
}

Expand All @@ -81,21 +83,82 @@ export function parseRSSXml(xml: string): ParsedFeedItem[] {
function extractTagRaw(xml: string, tag: string): string | null {
// Try CDATA first: <tag><![CDATA[content]]></tag>
const cdataRegex = new RegExp(
`<${escapeRegex(tag)}[^>]*>\\s*<!\\[CDATA\\[([\\s\\S]*?)\\]\\]>\\s*</${escapeRegex(tag)}>`,
String.raw`<${escapeRegex(tag)}\b[^>]*>\s*<!\[CDATA\[([\s\S]*?)\]\]>\s*</${escapeRegex(tag)}>`,
'i',
)
const cdataMatch = cdataRegex.exec(xml)
if (cdataMatch) return cdataMatch[1].trim()

// Plain text: <tag>content</tag>
const regex = new RegExp(
`<${escapeRegex(tag)}[^>]*>([\\s\\S]*?)</${escapeRegex(tag)}>`,
String.raw`<${escapeRegex(tag)}\b[^>]*>([\s\S]*?)</${escapeRegex(tag)}>`,
'i',
)
const match = regex.exec(xml)
return match ? match[1].trim() : null
}

/**
* Find the first feed-provided image that is both an image media item and a
* safe remote URL. Feed metadata is trusted only after this boundary check.
*/
function extractImage(block: string, contentRaw: string): string | null {
const thumbnail = safeHttpImageUrl(
extractAttr(block, 'media:thumbnail', 'url')
?? extractAttr(block, 'media:thumbnail', 'href'),
)
if (thumbnail) return thumbnail

for (const tag of ['enclosure', 'media:content']) {
const tagRegex = new RegExp(`<${escapeRegex(tag)}\\b[^>]*>`, 'gi')
let match: RegExpExecArray | null
while ((match = tagRegex.exec(block)) !== null) {
const tagText = match[0]
const url = extractAttr(tagText, tag, 'url')
if (!url || !isImageMedia(tagText, tag, url)) continue
const image = safeHttpImageUrl(url)
if (image) return image
}
}

const nestedImage = extractTagRaw(block, 'image')
const imageUrl = nestedImage ? extractTag(nestedImage, 'url') : null
const feedImage = safeHttpImageUrl(imageUrl)
if (feedImage) return feedImage

const htmlImage = /<img\b[^>]*>/i.exec(decodeXmlEntities(contentRaw))
return htmlImage ? safeHttpImageUrl(extractAttr(htmlImage[0], 'img', 'src')) : null
}

function isImageMedia(tagText: string, tag: string, url: string): boolean {
const type = extractAttr(tagText, tag, 'type')?.toLowerCase()
const medium = extractAttr(tagText, tag, 'medium')?.toLowerCase()
if (medium && medium !== 'image') return false
if (type && !type.startsWith('image/')) return false
return Boolean(type || medium || hasImageExtension(url))
}

function hasImageExtension(url: string): boolean {
try {
return /\.(?:avif|gif|jpe?g|png|svg|webp)$/i.test(new URL(url).pathname)
} catch {
return false
}
}

function safeHttpImageUrl(raw: string | null): string | null {
if (!raw) return null
const url = decodeXmlEntities(raw.trim())
try {
const parsed = new URL(url)
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null
if (parsed.username || parsed.password) return null
return url
} catch {
return null
}
}

/**
* Strip HTML tags first, then decode XML entities.
* Order matters: &lt;tag&gt; → (strip: no-op) → decode → "<tag>" (preserved)
Expand All @@ -110,15 +173,15 @@ function cleanText(raw: string): string {
function extractTag(xml: string, tag: string): string | null {
// Try CDATA first: <tag><![CDATA[content]]></tag>
const cdataRegex = new RegExp(
`<${escapeRegex(tag)}[^>]*>\\s*<!\\[CDATA\\[([\\s\\S]*?)\\]\\]>\\s*</${escapeRegex(tag)}>`,
String.raw`<${escapeRegex(tag)}\b[^>]*>\s*<!\[CDATA\[([\s\S]*?)\]\]>\s*</${escapeRegex(tag)}>`,
'i',
)
const cdataMatch = cdataRegex.exec(xml)
if (cdataMatch) return cdataMatch[1].trim()

// Plain text: <tag>content</tag>
const regex = new RegExp(
`<${escapeRegex(tag)}[^>]*>([\\s\\S]*?)</${escapeRegex(tag)}>`,
String.raw`<${escapeRegex(tag)}\b[^>]*>([\s\S]*?)</${escapeRegex(tag)}>`,
'i',
)
const match = regex.exec(xml)
Expand All @@ -130,9 +193,9 @@ function extractTag(xml: string, tag: string): string | null {
* e.g. <link href="https://..."/> → "https://..."
*/
function extractAttr(xml: string, tag: string, attr: string): string | null {
const regex = new RegExp(`<${escapeRegex(tag)}[^>]*${attr}="([^"]*)"`, 'i')
const regex = new RegExp(String.raw`<${escapeRegex(tag)}\b[^>]*\s${escapeRegex(attr)}\s*=(?:"([^"]*)"|'([^']*)')`, 'i')
const match = regex.exec(xml)
return match ? match[1] : null
return match ? (match[1] ?? match[2]) : null
}

/**
Expand Down
1 change: 1 addition & 0 deletions src/domain/news/collector/rss.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ export class NewsCollector {
ingestSource: 'rss',
dedupKey,
...(feed.categories ? { categories: feed.categories.join(',') } : {}),
...(item.image ? { image: item.image } : {}),
},
})

Expand Down
Loading