Skip to content

feat(unrest): surface protest source links#3492

Merged
koala73 merged 2 commits into
koala73:mainfrom
lspassos1:feat/protest-source-links
Apr 29, 2026
Merged

feat(unrest): surface protest source links#3492
koala73 merged 2 commits into
koala73:mainfrom
lspassos1:feat/protest-source-links

Conversation

@lspassos1

Copy link
Copy Markdown
Collaborator

Summary

Surfaces authoritative source links for unrest/protest events when GDELT provides a usable document URL. The map popup gets a compact source action so analysts can inspect the source behind a protest point instead of relying only on the normalized event text.

cc @koala73

Refs #131

Type of change

  • New feature
  • New map layer

Affected areas

  • Map / Globe
  • API endpoints (/api/*)
  • Other: unrest seed pipeline and proto-generated client/server types

Root cause

Unrest events already carried event context to the map, but the UI did not expose source links even when upstream GDELT rows included source URLs. That reduced map fidelity and made protest points harder to verify.

Changes

  • Add sourceUrl to the unrest event proto/type path and generated OpenAPI artifacts.
  • Preserve GDELT source URLs in the unrest seeder and server shared mapping.
  • Thread source URLs through the client service and map popup type.
  • Render a small source link in MapPopup when a protest/unrest event has a valid URL.
  • Add focused coverage for GDELT source URL extraction.

Validation

Risk

Low. The field is optional and the UI only renders the link when a source URL exists. Existing unrest events without source links keep the current popup behavior.

@vercel

vercel Bot commented Apr 28, 2026

Copy link
Copy Markdown

@lspassos1 is attempting to deploy a commit to the World Monitor Team on Vercel.

A member of the Team first needs to authorize it.

@greptile-apps

greptile-apps Bot commented Apr 28, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR threads sourceUrls through the full unrest event stack — proto definition, generated stubs, seed pipeline, server-side deduplication, client service, and MapPopup UI — so that GDELT document URLs are surfaced as clickable source links on protest map popups and cluster list items. The field is optional end-to-end and the UI renders links only when valid URLs are present, making the change safely additive for existing events.

Confidence Score: 4/5

Safe to merge — all findings are non-blocking style/cleanup suggestions with no functional regressions.

Only P2 findings: an empty container div edge case in MapPopup, a missing cap on the initial GDELT location entry, and an unbounded merge in the server-side deduplicator. The field is fully optional, URL sanitization is in place, and CI (typecheck, proto-freshness, unit, biome) passed.

scripts/seed-unrest-events.mjs (initial location entry missing 5-URL cap), server/worldmonitor/unrest/v1/_shared.ts (unbounded sourceUrls merge)

Important Files Changed

Filename Overview
scripts/seed-unrest-events.mjs Adds normalizeSourceUrl, extractGdeltSourceUrls, extractAcledSourceUrls, mergeSourceUrls helpers and threads sourceUrls through ACLED/GDELT event construction and deduplication; initial GDELT map entry missing the 5-URL cap applied on updates.
src/components/MapPopup.ts Adds source link rendering for individual protest popups and cluster list items; empty container div can be injected when all URLs fail client-side sanitization.
server/worldmonitor/unrest/v1/_shared.ts Merges sourceUrls in all three deduplication branches; no cap on the merged array size, which can grow beyond the 5-URL limit enforced in the seed script.
proto/worldmonitor/unrest/v1/unrest_event.proto Adds repeated string source_urls = 17 to UnrestEvent; field number and type are correct.
src/services/unrest/index.ts Maps sourceUrls from proto UnrestEvent to SocialUnrestEvent, omitting the field when empty — correctly aligns with optional typing in types/index.ts.
src/types/index.ts Adds optional sourceUrls?: string[] to SocialUnrestEvent, consistent with the service mapping and popup guard.
tests/seed-unrest-gdelt-fetch.test.mjs Extends existing proxy-retry test to assert both url and source_url properties from two different features are collected and deduplicated into sourceUrls.
src/generated/client/worldmonitor/unrest/v1/service_client.ts Generated file: adds non-optional sourceUrls: string[] to the client UnrestEvent interface; matches proto repeated field default semantics.
src/generated/server/worldmonitor/unrest/v1/service_server.ts Generated file: adds non-optional sourceUrls: string[] to the server UnrestEvent interface; consistent with client stub.
src/styles/main.css Adds .popup-source-links flex container styles and size override for .cluster-source-link; no issues.

Sequence Diagram

sequenceDiagram
    participant GDELT as GDELT API
    participant Seed as seed-unrest-events.mjs
    participant Server as _shared.ts (deduplicateEvents)
    participant API as /api/unrest/v1/list-unrest-events
    participant Svc as src/services/unrest/index.ts
    participant Popup as MapPopup.ts

    GDELT->>Seed: GeoJSON features (properties.url, .source_url, etc.)
    Seed->>Seed: extractGdeltSourceUrls() → normalizeSourceUrl() → uniqueSourceUrls()
    Seed->>Seed: mergeSourceUrls() + slice(0,5) per location
    Seed->>Server: UnrestEvent[] with sourceUrls[]
    Server->>Server: deduplicateEvents() — merge sourceUrls (no cap)
    Server->>API: ListUnrestEventsResponse (sourceUrls in each event)
    API->>Svc: UnrestEvent (sourceUrls: string[])
    Svc->>Svc: toSocialUnrestEvent() — sourceUrls?: string[] (omit if empty)
    Svc->>Popup: SocialUnrestEvent with optional sourceUrls
    Popup->>Popup: sanitizeUrl() + extractDomain() per URL
    Popup->>Popup: render up to 3 links in popup, 1 link per cluster item
Loading

Comments Outside Diff (1)

  1. scripts/seed-unrest-events.mjs, line 139-148 (link)

    P2 Initial GDELT location entry isn't subject to the 5-URL cap

    The slice(0, 5) cap is applied only when merging into an existing map entry (line 137), but the initial locationMap.set(...) path (lines 140-147) assigns extractGdeltSourceUrls(feature.properties) directly with no cap. extractGdeltSourceUrls checks seven property names, so a single feature with many URL fields could produce more than 5 entries on first insert, skipping the limit you're enforcing on updates.

Reviews (1): Last reviewed commit: "feat(unrest): surface protest source lin..." | Re-trigger Greptile

Comment thread src/components/MapPopup.ts Outdated
Comment on lines +1464 to +1471
const sourceLinks = event.sourceUrls?.length
? `<div class="popup-source-links">${event.sourceUrls.slice(0, 3).map((url, index) => {
const safeUrl = sanitizeUrl(url);
if (!safeUrl) return '';
const domain = extractDomain(url) || `${t('popups.source')} ${index + 1}`;
return `<a class="popup-link" href="${safeUrl}" target="_blank" rel="noopener noreferrer nofollow">${escapeHtml(domain)} →</a>`;
}).join('')}</div>`
: '';

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.

P2 Empty container div when all URLs fail client sanitization

event.sourceUrls?.length is truthy whenever the array is non-empty, but each item is still passed through sanitizeUrl and silently dropped (returning '') if it fails. If every URL in the array fails the client-side check the result is <div class="popup-source-links"></div> — an empty, invisible div injected into the popup. While harmless visually, it's cleaner to guard against this:

Suggested change
const sourceLinks = event.sourceUrls?.length
? `<div class="popup-source-links">${event.sourceUrls.slice(0, 3).map((url, index) => {
const safeUrl = sanitizeUrl(url);
if (!safeUrl) return '';
const domain = extractDomain(url) || `${t('popups.source')} ${index + 1}`;
return `<a class="popup-link" href="${safeUrl}" target="_blank" rel="noopener noreferrer nofollow">${escapeHtml(domain)} →</a>`;
}).join('')}</div>`
: '';
const filteredLinks = (event.sourceUrls ?? []).slice(0, 3).flatMap((url, index) => {
const safeUrl = sanitizeUrl(url);
if (!safeUrl) return [];
const domain = extractDomain(url) || `${t('popups.source')} ${index + 1}`;
return [`<a class="popup-link" href="${safeUrl}" target="_blank" rel="noopener noreferrer nofollow">${escapeHtml(domain)} →</a>`];
});
const sourceLinks = filteredLinks.length
? `<div class="popup-source-links">${filteredLinks.join('')}</div>`
: '';

Comment on lines +88 to +96
event.sourceUrls = [...new Set([...(event.sourceUrls || []), ...(existing.sourceUrls || [])])];
unique.set(key, event);
} else if (existing.sourceType === 'UNREST_SOURCE_TYPE_ACLED') {
existing.sources = [...new Set([...existing.sources, ...event.sources])];
existing.sourceUrls = [...new Set([...(existing.sourceUrls || []), ...(event.sourceUrls || [])])];
} else {
// Both GDELT: combine sources, upgrade confidence if 2+ sources
existing.sources = [...new Set([...existing.sources, ...event.sources])];
existing.sourceUrls = [...new Set([...(existing.sourceUrls || []), ...(event.sourceUrls || [])])];

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.

P2 No sourceUrls cap in server-side deduplication

The seed script caps GDELT sourceUrls at 5 per location, but the server-side deduplicateEvents spreads both sides into a new Set with no bound at all three merge branches. Over many ACLED+GDELT merges the array can grow beyond the intended limit and bloat the API response payload. Consider adding .slice(0, 5) (or a shared constant) after each new Set(...) spread.

@lspassos1

Copy link
Copy Markdown
Collaborator Author

Follow-up pushed in d0567730 for the source-link cleanup review.

Changes:

  • Centralized popup source-link rendering in a small pure helper so unsafe URLs are filtered before rendering the container.
  • Protest popups no longer emit an empty .popup-source-links wrapper when every URL is rejected by sanitization.
  • Capped unrest sourceUrls to 5 in both the seed pipeline and server-side dedupe path.
  • Added focused tests for dedupe capping and unsafe popup URLs.

Validation:

  • npx tsx --test tests/unrest-source-links.test.mts
  • node --test tests/seed-unrest-gdelt-fetch.test.mjs
  • npm run typecheck
  • git diff --check
  • npx biome lint ... completed with existing MapPopup.ts warnings only.

@koala73 koala73 merged commit 3bb7b8f into koala73:main Apr 29, 2026
9 of 10 checks passed
fuleinist pushed a commit to fuleinist/worldmonitor that referenced this pull request May 9, 2026
* feat(unrest): surface protest source links

* fix(unrest): cap protest source links
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