diff --git a/AGENTS.md b/AGENTS.md
new file mode 120000
index 000000000..681311eb9
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1 @@
+CLAUDE.md
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
index af4e184a9..32e2f6d6c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,44 @@ Format: weekly entries grouped by feature area.
---
+## 2026-03-25 — Desktop Polish
+
+### Added
+- Add inbox redesign with toolbar, filters, triage mode, link previews, and waveform visualizer
+- Add embedded tweet rendering with TweetCard component
+- Add reminder detail view with content routing
+- Add live URL title extraction and bot-page-title detection
+- Auto-title voice memos from first transcribed sentence
+- Add folder icon picker and NoteIconDisplay across tree and sidebar
+- Add HugeIcons rendering infrastructure
+- Sync accent color across devices with tint CSS variables
+- Add compact capture mode
+- Add PageToolbar and Pill UI primitives
+- Add Cmd+Z undo for all task CRUD and bulk operations
+- Add WCAG 2.1 AA accessibility pass — keyboard nav, ARIA, focus indicators
+- Add account section, recovery key dialog, shortcuts section, and capture shortcut to settings
+- Add Gelasio, Geist, and Inter font families
+
+### Fixed
+- Fix inbox filing auto-link and metascraper field mapping
+- Fix graph crash on null node data
+- Fix tag count casting and prune orphaned definitions
+- Fix project ID resolution before creating onboarding task
+
+### Changed
+- Extract shared settings primitives and redesign all settings pages
+- Replace custom toast notifications with Sonner
+- Remove non-Twitter platform support and simplify social URL parsing
+- Simplify sidebar with NoteIconDisplay and compact task count
+- Extract DetailHeader and simplify reminder TypeIcon
+- Migrate task design tokens and wire undoable task actions
+
+### Performance
+- Parallelize vault open with window creation to reduce cold-start time
+- Optimize startup I/O and large-vault indexing speed
+
+---
+
## 2026-03-19 — Tasks Refinement
### Added
diff --git a/apps/desktop/package.json b/apps/desktop/package.json
index 6f2d6a9cf..3efa8dc0f 100644
--- a/apps/desktop/package.json
+++ b/apps/desktop/package.json
@@ -66,7 +66,8 @@
"test:component": "vitest run --config config/vitest.config.ts tests/component",
"test:e2e": "playwright test --config config/playwright.config.ts",
"ipc:generate": "node scripts/generate-ipc-invoke-map.js",
- "ipc:check": "node scripts/generate-ipc-invoke-map.js --check"
+ "ipc:check": "node scripts/generate-ipc-invoke-map.js --check",
+ "generate:icons": "node scripts/generate-icons.mjs"
},
"dependencies": {
"@ai-sdk/anthropic": "^3.0.58",
@@ -186,6 +187,7 @@
"react-day-picker": "^9.13.0",
"react-pdf": "^10.3.0",
"react-player": "^3.4.0",
+ "react-tweet": "^3.3.0",
"sharp": "0.34.5",
"sigma": "^3.0.2",
"sodium-native": "^5.0.10",
@@ -209,9 +211,12 @@
"@electron/rebuild": "^4.0.3",
"@fontsource-variable/crimson-pro": "^5.2.8",
"@fontsource-variable/dm-sans": "^5.2.8",
+ "@fontsource-variable/geist": "^5.2.8",
+ "@fontsource-variable/inter": "^5.2.8",
"@fontsource-variable/jetbrains-mono": "^5.2.8",
"@fontsource-variable/playfair-display": "^5.2.8",
"@fontsource-variable/space-grotesk": "^5.2.10",
+ "@fontsource/gelasio": "^5.2.8",
"@fontsource/instrument-serif": "^5.2.8",
"@playwright/test": "^1.57.0",
"@testing-library/jest-dom": "^6.9.1",
diff --git a/apps/desktop/scripts/generate-icons.mjs b/apps/desktop/scripts/generate-icons.mjs
new file mode 100644
index 000000000..bcf22036d
--- /dev/null
+++ b/apps/desktop/scripts/generate-icons.mjs
@@ -0,0 +1,171 @@
+import { execFileSync } from 'node:child_process'
+import { mkdirSync, rmSync, writeFileSync } from 'node:fs'
+import { dirname, join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import sharp from 'sharp'
+
+const __dirname = dirname(fileURLToPath(import.meta.url))
+const BUILD_DIR = join(__dirname, '..', 'build')
+
+const ICON_SVG = `
+`
+
+const ICONSET_SIZES = [
+ { name: 'icon_16x16.png', size: 16 },
+ { name: 'icon_16x16@2x.png', size: 32 },
+ { name: 'icon_32x32.png', size: 32 },
+ { name: 'icon_32x32@2x.png', size: 64 },
+ { name: 'icon_128x128.png', size: 128 },
+ { name: 'icon_128x128@2x.png', size: 256 },
+ { name: 'icon_256x256.png', size: 256 },
+ { name: 'icon_256x256@2x.png', size: 512 },
+ { name: 'icon_512x512.png', size: 512 },
+ { name: 'icon_512x512@2x.png', size: 1024 }
+]
+
+const ICO_SIZES = [16, 32, 48, 64, 128, 256]
+
+async function renderPng(size) {
+ return sharp(Buffer.from(ICON_SVG)).resize(size, size).png().toBuffer()
+}
+
+function buildIco(pngBuffers, sizes) {
+ const headerSize = 6
+ const dirEntrySize = 16
+ const dirSize = dirEntrySize * pngBuffers.length
+ let dataOffset = headerSize + dirSize
+
+ const header = Buffer.alloc(headerSize)
+ header.writeUInt16LE(0, 0)
+ header.writeUInt16LE(1, 2)
+ header.writeUInt16LE(pngBuffers.length, 4)
+
+ const dirEntries = []
+ const offsets = []
+ for (let i = 0; i < pngBuffers.length; i++) {
+ offsets.push(dataOffset)
+ dataOffset += pngBuffers[i].length
+ }
+
+ for (let i = 0; i < pngBuffers.length; i++) {
+ const entry = Buffer.alloc(dirEntrySize)
+ entry.writeUInt8(sizes[i] >= 256 ? 0 : sizes[i], 0)
+ entry.writeUInt8(sizes[i] >= 256 ? 0 : sizes[i], 1)
+ entry.writeUInt8(0, 2)
+ entry.writeUInt8(0, 3)
+ entry.writeUInt16LE(1, 4)
+ entry.writeUInt16LE(32, 6)
+ entry.writeUInt32LE(pngBuffers[i].length, 8)
+ entry.writeUInt32LE(offsets[i], 12)
+ dirEntries.push(entry)
+ }
+
+ return Buffer.concat([header, ...dirEntries, ...pngBuffers])
+}
+
+async function generateIcns() {
+ if (process.platform !== 'darwin') {
+ console.warn(' [skip] .icns generation requires macOS (iconutil)')
+ return
+ }
+
+ const iconsetDir = join(BUILD_DIR, 'icon.iconset')
+ mkdirSync(iconsetDir, { recursive: true })
+
+ await Promise.all(
+ ICONSET_SIZES.map(async ({ name, size }) => {
+ const buf = await renderPng(size)
+ writeFileSync(join(iconsetDir, name), buf)
+ })
+ )
+
+ const output = join(BUILD_DIR, 'icon.icns')
+ execFileSync('iconutil', ['-c', 'icns', '-o', output, iconsetDir])
+ rmSync(iconsetDir, { recursive: true })
+ console.log(' icon.icns')
+}
+
+async function generateIco() {
+ const pngBuffers = await Promise.all(ICO_SIZES.map((s) => renderPng(s)))
+ const ico = buildIco(pngBuffers, ICO_SIZES)
+ writeFileSync(join(BUILD_DIR, 'icon.ico'), ico)
+ console.log(' icon.ico')
+}
+
+async function generatePng() {
+ const buf = await renderPng(1024)
+ writeFileSync(join(BUILD_DIR, 'icon.png'), buf)
+ console.log(' icon.png')
+}
+
+async function main() {
+ mkdirSync(BUILD_DIR, { recursive: true })
+ console.log('Generating app icons...')
+ await Promise.all([generateIcns(), generateIco(), generatePng()])
+ console.log('Done.')
+}
+
+main().catch((err) => {
+ console.error('Icon generation failed:', err)
+ process.exit(1)
+})
diff --git a/apps/desktop/scripts/preview-icon-variants.mjs b/apps/desktop/scripts/preview-icon-variants.mjs
new file mode 100644
index 000000000..e9ec18a23
--- /dev/null
+++ b/apps/desktop/scripts/preview-icon-variants.mjs
@@ -0,0 +1,375 @@
+import { mkdirSync, writeFileSync } from 'node:fs'
+import { dirname, join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import sharp from 'sharp'
+
+const __dirname = dirname(fileURLToPath(import.meta.url))
+const PREVIEW_DIR = join(__dirname, '..', 'build', 'previews')
+
+const COLORS = {
+ inbox: '#6366f1',
+ journal: '#8b5cf6',
+ task: '#d4944a',
+ note: '#4a9e8e'
+}
+
+const SHARED_DEFS = `
+
' - } - - mockFetch.mockResolvedValueOnce({ ok: false, status: 500 }).mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve(oembedResponse) - }) + vi.mocked(mockIsSocialPost).mockReturnValue(false) - const result = await extractSocialPost('https://twitter.com/testuser/status/123') + const result = extractSocialPost('https://twitter.com/user') expect(result.success).toBe(true) - expect(result.metadata).toBeDefined() - expect(result.metadata?.platform).toBe('twitter') - expect(result.metadata?.authorName).toBe('Test User') - expect(result.metadata?.authorHandle).toBe('@testuser') - expect(result.metadata?.postContent).toContain('test tweet') - }) - - it('should handle protected/deleted tweets', async () => { - mockFetch - .mockResolvedValueOnce({ ok: false, status: 404 }) - .mockResolvedValueOnce({ ok: false, status: 404 }) - - const result = await extractSocialPost('https://twitter.com/user/status/deleted') - - expect(result.success).toBe(false) - expect(result.error).toContain('not found') + expect(result.metadata?.extractionStatus).toBe('partial') + expect(result.metadata?.tweetId).toBeUndefined() }) - it('should handle Twitter API errors', async () => { - mockFetch - .mockResolvedValueOnce({ ok: false, status: 500 }) - .mockResolvedValueOnce({ ok: false, status: 500 }) + it('should extract tweetId from twitter.com status URL', () => { + vi.mocked(mockDetectSocialPlatform).mockReturnValue('twitter') + vi.mocked(mockIsSocialPost).mockReturnValue(true) - const result = await extractSocialPost('https://twitter.com/user/status/123') + const result = extractSocialPost('https://twitter.com/elonmusk/status/1234567890') - expect(result.success).toBe(false) - expect(result.error).toContain('500') + expect(result.success).toBe(true) + expect(result.metadata?.tweetId).toBe('1234567890') + expect(result.metadata?.platform).toBe('twitter') + expect(result.metadata?.postUrl).toBe('https://twitter.com/elonmusk/status/1234567890') }) - it('should parse HTML entities in tweet content', async () => { - const oembedResponse = { - author_name: 'User', - author_url: 'https://twitter.com/user', - html: 'This is a test tweet!
— Test User (@testuser) December 28, 2025
' - } - - mockFetch.mockResolvedValueOnce({ ok: false, status: 500 }).mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve(oembedResponse) - }) + it('should extract tweetId from x.com status URL', () => { + vi.mocked(mockDetectSocialPlatform).mockReturnValue('twitter') + vi.mocked(mockIsSocialPost).mockReturnValue(true) - const result = await extractSocialPost('https://twitter.com/user/status/123') + const result = extractSocialPost('https://x.com/user/status/9876543210') - expect(result.metadata?.postContent).toContain('&') - expect(result.metadata?.postContent).toContain('more') + expect(result.success).toBe(true) + expect(result.metadata?.tweetId).toBe('9876543210') }) - }) - // ========================================================================== - // T433: extractSocialPost - Mastodon and Bluesky - // ========================================================================== - describe('extractSocialPost - Mastodon', () => { - beforeEach(() => { - vi.mocked(mockDetectSocialPlatform).mockReturnValue('mastodon') + it('should handle URLs with query params and fragments', () => { + vi.mocked(mockDetectSocialPlatform).mockReturnValue('twitter') vi.mocked(mockIsSocialPost).mockReturnValue(true) - }) - - it('should extract metadata from Mastodon oEmbed', async () => { - const oembedResponse = { - author_name: 'User Name (@user@mastodon.social)', - title: 'This is a toot!', - html: '' - } - - mockFetch.mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve(oembedResponse) - }) - const result = await extractSocialPost('https://mastodon.social/@user/123') + const result = extractSocialPost('https://twitter.com/user/status/111222333?s=20&t=abc#top') expect(result.success).toBe(true) - expect(result.metadata?.platform).toBe('mastodon') - expect(result.metadata?.authorHandle).toContain('@user') + expect(result.metadata?.tweetId).toBe('111222333') }) - it('should handle Mastodon API errors', async () => { - mockFetch.mockResolvedValueOnce({ - ok: false, - status: 404 - }) + it('should extract handle from URL path', () => { + vi.mocked(mockDetectSocialPlatform).mockReturnValue('twitter') + vi.mocked(mockIsSocialPost).mockReturnValue(true) - const result = await extractSocialPost('https://mastodon.social/@user/123') + const result = extractSocialPost('https://twitter.com/rauchg/status/123456') - expect(result.success).toBe(false) + expect(result.metadata?.authorHandle).toBe('@rauchg') }) - it('should use instance-specific oEmbed endpoint', async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve({ author_name: 'User' }) - }) + it('should be synchronous (no network requests)', () => { + vi.mocked(mockDetectSocialPlatform).mockReturnValue('twitter') + vi.mocked(mockIsSocialPost).mockReturnValue(true) - await extractSocialPost('https://fosstodon.org/@user/123') + const mockFetch = vi.fn() + global.fetch = mockFetch - expect(mockFetch).toHaveBeenCalledWith( - expect.stringContaining('fosstodon.org/api/oembed'), - expect.anything() - ) - }) - }) + extractSocialPost('https://twitter.com/user/status/123') - describe('extractSocialPost - Bluesky', () => { - beforeEach(() => { - vi.mocked(mockDetectSocialPlatform).mockReturnValue('bluesky') - vi.mocked(mockIsSocialPost).mockReturnValue(true) + expect(mockFetch).not.toHaveBeenCalled() }) - it('should extract metadata from Bluesky API', async () => { - const apiResponse = { - thread: { - post: { - author: { - displayName: 'Test User', - handle: 'testuser.bsky.social', - avatar: 'https://avatar.url' - }, - record: { - text: 'This is a Bluesky post!', - createdAt: '2025-01-02T12:00:00Z' - }, - likeCount: 10, - repostCount: 5, - replyCount: 2 - } - } - } - - mockFetch.mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve(apiResponse) - }) - - const result = await extractSocialPost( - 'https://bsky.app/profile/testuser.bsky.social/post/abc123' - ) - - expect(result.success).toBe(true) - expect(result.metadata?.platform).toBe('bluesky') - expect(result.metadata?.authorName).toBe('Test User') - expect(result.metadata?.authorHandle).toBe('@testuser.bsky.social') - expect(result.metadata?.postContent).toBe('This is a Bluesky post!') - expect(result.metadata?.metrics?.likes).toBe(10) - }) + it('should set extractionStatus to partial (react-tweet handles full fetch)', () => { + vi.mocked(mockDetectSocialPlatform).mockReturnValue('twitter') + vi.mocked(mockIsSocialPost).mockReturnValue(true) - it('should handle invalid Bluesky URL format', async () => { - const result = await extractSocialPost('https://bsky.app/invalid/path') + const result = extractSocialPost('https://twitter.com/user/status/123') - expect(result.success).toBe(false) - expect(result.error).toContain('Invalid') + expect(result.metadata?.extractionStatus).toBe('partial') }) - it('should fall back to partial metadata on API error', async () => { - mockFetch.mockResolvedValueOnce({ - ok: false, - status: 400 - }) + it('should return empty postContent (react-tweet handles display)', () => { + vi.mocked(mockDetectSocialPlatform).mockReturnValue('twitter') + vi.mocked(mockIsSocialPost).mockReturnValue(true) - const result = await extractSocialPost('https://bsky.app/profile/user.bsky.social/post/abc') + const result = extractSocialPost('https://twitter.com/user/status/123') - expect(result.success).toBe(true) - expect(result.metadata?.extractionStatus).toBe('partial') + expect(result.metadata?.postContent).toBe('') }) }) - // ========================================================================== - // T434: createFallbackSocialMetadata and edge cases - // ========================================================================== describe('createFallbackSocialMetadata', () => { it('should create fallback metadata with URL', () => { const fallback = createFallbackSocialMetadata( @@ -302,94 +151,7 @@ describe('Social Media Post Extraction', () => { it('should handle "other" platform type', () => { const fallback = createFallbackSocialMetadata('https://unknown.social/post', 'other') - expect(fallback.platform).toBe('other') }) }) - - describe('extractSocialPost - LinkedIn', () => { - beforeEach(() => { - vi.mocked(mockDetectSocialPlatform).mockReturnValue('linkedin') - vi.mocked(mockIsSocialPost).mockReturnValue(true) - }) - - it('should return partial metadata for LinkedIn (no public API)', async () => { - const result = await extractSocialPost('https://linkedin.com/posts/user_activity-123') - - expect(result.success).toBe(true) - expect(result.metadata?.platform).toBe('linkedin') - expect(result.metadata?.extractionStatus).toBe('partial') - }) - }) - - describe('extractSocialPost - Threads', () => { - beforeEach(() => { - vi.mocked(mockDetectSocialPlatform).mockReturnValue('threads') - vi.mocked(mockIsSocialPost).mockReturnValue(true) - }) - - it('should extract username from Threads URL', async () => { - const result = await extractSocialPost('https://www.threads.net/@username/post/abc123') - - expect(result.success).toBe(true) - expect(result.metadata?.platform).toBe('threads') - expect(result.metadata?.authorHandle).toBe('@username') - expect(result.metadata?.extractionStatus).toBe('partial') - }) - - it('should handle Threads URLs without username', async () => { - const result = await extractSocialPost('https://www.threads.net/post/abc123') - - expect(result.success).toBe(true) - expect(result.metadata?.authorHandle).toBe('') - }) - }) - - describe('extractSocialPost - Edge Cases', () => { - it('should return error for unrecognized platform', async () => { - vi.mocked(mockDetectSocialPlatform).mockReturnValue(null) - - const result = await extractSocialPost('https://example.com/not-social') - - expect(result.success).toBe(false) - expect(result.error).toContain('not from a recognized') - }) - - it('should handle non-post URLs (profile pages)', async () => { - vi.mocked(mockDetectSocialPlatform).mockReturnValue('twitter') - vi.mocked(mockIsSocialPost).mockReturnValue(false) - - const result = await extractSocialPost('https://twitter.com/user') - - expect(result.success).toBe(true) - expect(result.metadata?.extractionStatus).toBe('partial') - }) - - it('should handle network timeouts gracefully', async () => { - vi.mocked(mockDetectSocialPlatform).mockReturnValue('twitter') - vi.mocked(mockIsSocialPost).mockReturnValue(true) - - mockFetch - .mockRejectedValueOnce(new Error('Network timeout')) - .mockRejectedValueOnce(new Error('Network timeout')) - - const result = await extractSocialPost('https://twitter.com/user/status/123') - - expect(result.success).toBe(false) - expect(result.error).toContain('timeout') - }) - - it('should handle AbortError for timeouts', async () => { - vi.mocked(mockDetectSocialPlatform).mockReturnValue('twitter') - vi.mocked(mockIsSocialPost).mockReturnValue(true) - - const abortError = new Error('Aborted') - abortError.name = 'AbortError' - mockFetch.mockRejectedValueOnce(abortError).mockRejectedValueOnce(abortError) - - const result = await extractSocialPost('https://twitter.com/user/status/123') - - expect(result.success).toBe(false) - }) - }) }) diff --git a/apps/desktop/src/main/inbox/social.ts b/apps/desktop/src/main/inbox/social.ts index 90d88bf1e..78c9ee6dc 100644 --- a/apps/desktop/src/main/inbox/social.ts +++ b/apps/desktop/src/main/inbox/social.ts @@ -1,30 +1,5 @@ -/* eslint-disable @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call */ -// External social media APIs return dynamic JSON structures - type safety not feasible without extensive runtime validation - -/** - * Social Media Post Extraction - * - * Handles extraction of metadata from social media posts (Twitter/X, LinkedIn, - * Mastodon, Bluesky, Threads). Uses oEmbed when available, falls back to - * metascraper for basic metadata extraction. - * - * @module main/inbox/social - */ - -import { createLogger } from '../lib/logger' import type { SocialMetadata } from '@memry/contracts/inbox-api' -import { - detectSocialPlatform, - isSocialPost, - extractDomain, - type SocialPlatform -} from '../lib/url-utils' - -const log = createLogger('Inbox:Social') - -// ============================================================================ -// Types -// ============================================================================ +import { detectSocialPlatform, isSocialPost, type SocialPlatform } from '../lib/url-utils' export interface SocialExtractionResult { success: boolean @@ -32,562 +7,21 @@ export interface SocialExtractionResult { error?: string } -interface OEmbedResponse { - type: string - version: string - title?: string - author_name?: string - author_url?: string - provider_name?: string - provider_url?: string - html?: string - width?: number - height?: number - url?: string -} - -interface TwitterOEmbedResponse extends OEmbedResponse { - author_name: string - author_url: string - html: string -} - -// ============================================================================ -// Constants -// ============================================================================ - -/** oEmbed endpoints for supported platforms */ -const OEMBED_ENDPOINTS: RecordTest & more
— User (@user) Date
- */ -function parseTwitterEmbedHtml(html: string): { - content: string - authorName: string - authorHandle: string - timestamp?: string -} { - // Extract content fromTweet content...
- * — Author Name (@handle) Date
tag - const contentMatch = html.match(/
]*>([\s\S]*?)<\/p>/)
- let content = contentMatch ? contentMatch[1] : ''
-
- // Clean up HTML entities and tags
- content = content
- .replace(/]*>(.*?)<\/a>/g, '$1') // Keep link text, remove tag
- .replace(/
/g, '\n') // Convert br to newlines
- .replace(/—/g, '—')
- .replace(/&/g, '&')
- .replace(/</g, '<')
- .replace(/>/g, '>')
- .replace(/"/g, '"')
- .replace(/'/g, "'")
- .replace(/<[^>]+>/g, '') // Remove remaining HTML tags
- .trim()
-
- // Extract author info from "— Author Name (@handle)"
- const authorMatch = html.match(/—\s*([^(]+)\s*\(@([^)]+)\)/)
- const authorName = authorMatch ? authorMatch[1].trim() : ''
- const authorHandle = authorMatch ? authorMatch[2].trim() : ''
-
- // Extract timestamp from the date link
- const timestampMatch = html.match(/([A-Za-z]+ \d+, \d+)<\/a>\s*<\/blockquote>/)
- const timestamp = timestampMatch ? timestampMatch[1] : undefined
-
- return { content, authorName, authorHandle, timestamp }
-}
-
-/**
- * Extract handle from Twitter/X author URL
- * e.g., "https://twitter.com/username" -> "username"
- */
-function extractHandleFromUrl(authorUrl: string): string {
- try {
- const url = new URL(authorUrl)
- const pathParts = url.pathname.split('/').filter(Boolean)
- return pathParts[0] || ''
- } catch {
- return ''
- }
-}
-
-/**
- * Extract tweet ID from a Twitter/X URL
- * e.g., "https://x.com/user/status/123456" -> "123456"
- */
function extractTweetId(url: string): string | null {
const match = url.match(/\/status\/(\d+)/)
return match ? match[1] : null
}
-// ============================================================================
-// Platform-Specific Extractors
-// ============================================================================
-
-/**
- * Fetch full tweet text via Twitter's Syndication API.
- * This endpoint powers Twitter's embed widget and returns the complete tweet
- * text — including content behind the "Show more" fold that oEmbed omits.
- */
-async function fetchTweetViaSyndication(tweetId: string): Promise<{
- text: string
- authorName: string
- authorHandle: string
- authorAvatar?: string
- timestamp?: string
- mediaUrls: string[]
-} | null> {
- try {
- const syndicationUrl = `https://cdn.syndication.twimg.com/tweet-result?id=${tweetId}&lang=en&token=x`
- log.debug(`Fetching tweet via Syndication API: ${syndicationUrl}`)
-
- const response = await fetchWithTimeout(syndicationUrl, {
- headers: {
- Accept: 'application/json',
- 'User-Agent': 'Memry/1.0'
- }
- })
-
- if (!response.ok) {
- log.debug(`Syndication API returned ${response.status}`)
- return null
- }
-
- const data = await response.json()
-
- log.info(`[DEBUG] Syndication API response keys: ${Object.keys(data).join(', ')}`)
- log.info(`[DEBUG] Syndication text length: ${(data.text || '').length}`)
- log.info(`[DEBUG] Syndication text:\n${data.text}`)
-
- if (!data.text) {
- log.debug('Syndication API returned no text')
- return null
- }
-
- const mediaUrls: string[] = []
- if (data.mediaDetails && Array.isArray(data.mediaDetails)) {
- for (const media of data.mediaDetails) {
- if (media.media_url_https) {
- mediaUrls.push(media.media_url_https as string)
- }
- }
- }
- if (data.photos && Array.isArray(data.photos)) {
- for (const photo of data.photos) {
- if (photo.url) {
- mediaUrls.push(photo.url as string)
- }
- }
- }
-
- return {
- text: data.text,
- authorName: data.user?.name || '',
- authorHandle: data.user?.screen_name || '',
- authorAvatar: data.user?.profile_image_url_https,
- timestamp: data.created_at,
- mediaUrls
- }
- } catch (error) {
- const message = error instanceof Error ? error.message : 'Unknown error'
- log.debug(`Syndication API failed: ${message}`)
- return null
- }
-}
-
-/**
- * Extract metadata from Twitter/X posts.
- *
- * Strategy:
- * 1. Try Syndication API first — returns full tweet text (including "Show more" content)
- * 2. Fall back to oEmbed API — only returns above-the-fold text for long tweets
- */
-async function extractTwitterPost(url: string): Promise