offline-first, p2p synced, atproto enabled, feed reader
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

handle read state, and realize how indexing has to work

+113 -47
+2 -11
src/app/components/feed-row.tsx
··· 15 15 16 16 const feedurl = createMemo(() => encodeURIComponent(props.feed.url)) 17 17 18 - const totalCount = makeSignalQuery(async () => 19 - await feedline.entries.where('feedurl').equals(props.feed.url).count() 18 + const unreadCount = makeSignalQuery(async () => 19 + await feedline.entries.where('[feedurl+idx_read]').equals([props.feed.url, 0]).count() 20 20 ) 21 - 22 - const readCount = makeSignalQuery(async () => 23 - await feedline.entries 24 - .where('[feedurl+progress.position]') 25 - .between([props.feed.url, 0], [props.feed.url, [[]]]) 26 - .count() 27 - ) 28 - 29 - const unreadCount = () => (totalCount() ?? 0) - (readCount() ?? 0) 30 21 31 22 return ( 32 23 <li>
+2 -12
src/app/layout/sidenav/index.tsx
··· 68 68 const [publishedAt] = createDate(props.feed.lastPublishedAt ?? props.feed.lastBuildAt ?? '1999-12-31') 69 69 const [published] = createTimeAgo(publishedAt) 70 70 71 - const totalCount = makeSignalQuery( 72 - async () => await feedline.entries.where('feedurl').equals(props.feed.url).count(), 73 - ) 74 - 75 - const readCount = makeSignalQuery( 76 - async () => 77 - await feedline.entries 78 - .where('[feedurl+progress.position]') 79 - .between([props.feed.url, 0], [props.feed.url, [[]]]) 80 - .count(), 71 + const unreadCount = makeSignalQuery( 72 + async () => await feedline.entries.where('[feedurl+idx_read]').equals([props.feed.url, 0]).count(), 81 73 ) 82 - 83 - const unreadCount = () => (totalCount() ?? 0) - (readCount() ?? 0) 84 74 85 75 return ( 86 76 <li>
+12 -4
src/app/pages/entry/index.tsx
··· 56 56 } 57 57 58 58 createEffect(() => { 59 - console.log('live query', entry()?.title) 59 + console.log('loaded entry', entry()) 60 60 }) 61 61 62 62 // mark as read after delay if not already read, and on unload ··· 65 65 if (!entry) return 66 66 67 67 // already read - nothing to do on mount 68 - const alreadyRead = entry.progress !== undefined 68 + const alreadyRead = entry.progress !== null 69 69 70 70 // otherwise start the timer 71 71 let timeoutId: ReturnType<typeof setTimeout> | undefined ··· 107 107 return publishedAt && new Date(publishedAt).toLocaleDateString() 108 108 } 109 109 110 + const content = () => { 111 + const html = entry()?.content || entry()?.snippet 112 + if (!html) return null 113 + 114 + const href = entry()?.linkUrl || 'about:blank' 115 + return `<base href="${href}">${html}` 116 + } 117 + 110 118 return ( 111 119 <NavigationPage class={styles['entry']}> 112 120 <Show when={entry()} fallback={<p>Entry not found</p>}> ··· 129 137 <button onClick={setTitle}>Change Title</button> 130 138 </header> 131 139 132 - <Show when={entry().content || entry().snippet}> 133 - {(content) => <iframe srcdoc={content()} />} 140 + <Show when={content()}> 141 + {(content) => <iframe srcdoc={content()} sandbox="allow-same-origin" />} 134 142 </Show> 135 143 136 144 <Show when={enclosureAudio()}>
+1 -1
src/app/pages/feed/index.tsx
··· 97 97 98 98 function FeedEntryRow(props: {entry: Entry; feedurl: string, pass: ComponentProps<'li'>}) { 99 99 const feedurl = createMemo(() => encodeURIComponent(props.feedurl)) 100 - const unread = createMemo(() => props.entry.progress === undefined) 100 + const unread = createMemo(() => props.entry.progress === null) 101 101 const guid = createMemo(() => encodeURIComponent(props.entry.guid)) 102 102 const date = createMemo( 103 103 () => props.entry.publishedAt && new Date(props.entry.publishedAt).toLocaleDateString(),
+16 -7
src/feedline/client/action-handler.ts
··· 100 100 101 101 async #feedAdd(action: FeedAddAction) { 102 102 try { 103 - await this.#db.feeds.add({ 103 + const feed = this.#db.enrichFeed({ 104 104 url: action.dat.url, 105 105 lock: action.dat.lock ?? null, 106 106 status: 'pending', ··· 112 112 tags: [], 113 113 meta: {}, 114 114 }) 115 + 116 + await this.#db.feeds.add(feed) 115 117 } catch (exc) { 116 118 // skip duplicates - feed already exists 117 119 if (exc instanceof Dexie.ConstraintError) return ··· 124 126 } 125 127 126 128 async #feedPatch(action: FeedPatchAction) { 127 - // this should check locks and clocks 128 - await this.#db.feeds.update(action.dat.url, action.dat.payload) 129 + // TODO: this should check locks and clocks 130 + const update = this.#db.enrichFeed(action.dat.payload) 131 + await this.#db.feeds.update(action.dat.url, update) 129 132 } 130 133 131 134 async #entryPatch(action: EntryPatchAction) { 132 - // this should check locks and clocks 135 + // TODO: this should check locks and clocks 133 136 try { 134 137 await this.#db.transaction('rw', ['feeds', 'entries'], async (tx) => { 135 138 const feed = await tx.feeds.get(action.dat.feedurl) ··· 138 141 const key: [string, string] = [action.dat.feedurl, action.dat.guid] 139 142 const extant = await tx.entries.get(key) 140 143 if (extant) { 141 - await tx.entries.update(key, action.dat.payload) 144 + const update = this.#db.enrichEntry(action.dat.payload) 145 + await tx.entries.update(key, update) 142 146 } else { 143 - await tx.entries.add({ 147 + const entry = this.#db.enrichEntry({ 144 148 tags: [], 145 149 links: [], 146 150 meta: {}, 147 151 lock: null, 152 + progress: null, 148 153 // defaults ^^ 149 154 ...action.dat.payload, 150 155 // overrides vv 151 156 guid: action.dat.guid, 152 157 feedurl: action.dat.feedurl, 153 158 }) 159 + 160 + await tx.entries.add(entry) 154 161 } 155 162 }) 156 163 return ··· 171 178 return 172 179 } 173 180 174 - await this.#db.entries.update(key, { 181 + const update = this.#db.enrichEntry({ 175 182 progress: { 176 183 position: action.dat.position, 177 184 lastUpdated: action.clk, 178 185 }, 179 186 }) 187 + 188 + await this.#db.entries.update(key, update) 180 189 } 181 190 }
+37 -6
src/feedline/client/database.ts
··· 5 5 import {Timestamp, elapsed} from '#realm/schema/timestamp' 6 6 7 7 import {Entry} from '#feedline/schema/entry' 8 - import {Feed} from '#feedline/schema/feed' 8 + import {Feed, tagString} from '#feedline/schema/feed' 9 9 import {Lock} from '#feedline/schema/lock' 10 10 11 11 export type Lockable = {lock: Lock | null} 12 + 13 + export type FeedRow = Feed & { 14 + idx_tags: string[] 15 + } 16 + 17 + export type EntryRow = Entry & { 18 + idx_read?: 0 | 1 19 + idx_tags: string[] 20 + } 12 21 13 22 export class FeedlineDatabase extends Dexie { 14 - feeds!: Table<Feed, string> 15 - entries!: Table<Entry> 23 + feeds!: Table<FeedRow> 24 + entries!: Table<EntryRow> 16 25 17 26 constructor(identid: IdentID) { 18 27 super(`feedline-${identid}`) 19 28 20 29 this.version(1).stores({ 21 - feeds: '&url, *tags.tag, lock.at, lock.by, status, medium', 22 - entries: 23 - '&[feedurl+guid], *tags.tag, lock.at, lock.by, publishedAt, fetchedAt, [feedurl+progress.position]', 30 + feeds: '&url, lock.at, lock.by, status, medium, *idx_tags', 31 + entries: '&[feedurl+guid], lock.at, lock.by, publishedAt, fetchedAt, *idx_tags, [feedurl+idx_read]' 24 32 }) 33 + } 34 + 35 + enrichFeed(feed_: Feed): FeedRow 36 + enrichFeed(feed_: Partial<Feed>): Partial<FeedRow> 37 + enrichFeed(feed_: Feed | Partial<Feed>): Feed | Partial<FeedRow> { 38 + const feed = feed_ as FeedRow 39 + 40 + feed.idx_tags = feed.tags?.map(tagString) 41 + 42 + return feed 43 + } 44 + 45 + enrichEntry(entry_: Entry): EntryRow 46 + enrichEntry(entry_: Partial<Entry>): Partial<EntryRow> 47 + enrichEntry(entry_: Entry | Partial<Entry>): Entry | Partial<Entry> { 48 + const entry = entry_ as Partial<EntryRow> 49 + 50 + entry.idx_read = entry.progress === null ? 0 : 1; 51 + if (entry.tags) { 52 + entry.idx_tags = entry.tags.map(tagString) 53 + } 54 + 55 + return entry 25 56 } 26 57 27 58 /**
+29 -3
src/feedline/client/feed-fetcher.ts
··· 284 284 } 285 285 } 286 286 287 + // guess the mime based on the file extension 288 + function urlToMime(url_: string): string | null { 289 + try { 290 + const url = URL.parse(url_) 291 + if (!url) return null 292 + 293 + const path = url.pathname.split('/').pop() 294 + const match = path?.match(/\.([a-z0-9]+)$/i) 295 + switch (match?.[1]) { 296 + case 'mp3': return 'audio/mpeg' 297 + case 'm4a': return 'audio/mp4' 298 + case 'ogg': return 'audio/ogg' 299 + case 'jpg': 300 + case 'jpeg': return 'image/jpeg' 301 + case 'png': return 'image/png' 302 + 303 + default: 304 + return null 305 + } 306 + } catch { 307 + return null 308 + } 309 + } 310 + 287 311 function mapRssToEntry(feedurl: string, item: Rss.Item<string>, fetchedAt: number): Entry { 288 312 const entryTags: Tag[] = [] 289 313 const entryLinks: Link[] = [] ··· 339 363 } 340 364 341 365 // Get primary enclosure 342 - const enclosure = item.enclosures?.[0] 366 + const enclosure: Entry['enclosure'] = item.enclosures?.[0] 343 367 ? { 344 368 url: item.enclosures[0].url, 345 - mime: item.enclosures[0].type, 369 + mime: item.enclosures[0].type || urlToMime(item.enclosures[0].url), 346 370 length: item.enclosures[0].length, 347 371 } 348 372 : undefined ··· 351 375 feedurl, 352 376 guid: item.guid?.value ?? item.link ?? `${feedurl}#${item.title}`, 353 377 lock: null, 378 + progress: null, 354 379 tags: entryTags, 355 380 title: item.title ?? null, 356 381 snippet: item.description ?? null, ··· 450 475 const enclosure = enclosureLink 451 476 ? { 452 477 url: enclosureLink.href, 453 - mime: enclosureLink.type ?? null, 478 + mime: enclosureLink.type || urlToMime(enclosureLink.href), 454 479 length: enclosureLink.length ?? null, 455 480 } 456 481 : undefined ··· 459 484 feedurl, 460 485 guid: entry.id, 461 486 lock: null, 487 + progress: null, 462 488 tags: entryTags, 463 489 title: entry.title, 464 490 snippet: entry.summary ?? null,
+1 -2
src/feedline/schema/entry.ts
··· 10 10 guid: z.string(), 11 11 lock: lockSchema.nullable(), 12 12 13 - // for db indexing 14 13 progress: z 15 14 .object({ 16 15 position: z.number(), // seconds or pixels 17 16 lastUpdated: logicalClockSchema, 18 17 }) 19 - .optional(), 18 + .nullable(), 20 19 21 20 title: z.string().nullish(), 22 21 snippet: z.string().nullish(),
+13 -1
src/feedline/schema/feed.ts
··· 24 24 z.object({tag: z.literal('podcast.episode'), value: z.string()}), 25 25 ]) 26 26 27 + export type Tag = z.infer<typeof tagSchema> 28 + 29 + export function tagString(tag: Tag): string { 30 + switch(tag.tag) { 31 + case 'explicit': return 'explicit' 32 + case 'category': return `category:${tag.value}` 33 + case 'language': return `language:${tag.value}` 34 + case 'person': return `person:${tag.value}${tag.rel ? ":" + tag.rel : ""}` 35 + case 'podcast.season': return `podcast:season:${tag.value}` 36 + case 'podcast.episode': return `podcast:episode:${tag.value}` 37 + } 38 + } 39 + 27 40 export const feedSchema = z.object({ 28 41 url: z.url(), 29 42 lock: lockSchema.nullable(), ··· 68 81 69 82 export type Feed = z.infer<typeof feedSchema> 70 83 export type Link = z.infer<typeof linkSchema> 71 - export type Tag = z.infer<typeof tagSchema>