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

Configure Feed

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

some feed row stuff

+311 -116
+36
src/app/components/badge.module.css
··· 1 + .badge { 2 + background: var(--theme-color); 3 + color: var(--theme-bg); 4 + 5 + display: flex; 6 + color: light-dark( 7 + color-mix(in srgb, var(--theme-color), white 20%), 8 + color-mix(in srgb, var(--theme-color), black 20%) 9 + ); 10 + background: light-dark( 11 + color-mix(in srgb, var(--theme-bg), black 20%), 12 + color-mix(in srgb, var(--theme-bg), white 20%) 13 + ); 14 + 15 + gap: 2px; 16 + border-radius: 4px; 17 + padding: 2px; 18 + 19 + .label { 20 + } 21 + 22 + .value { 23 + padding: 2px; 24 + border-radius: 4px; 25 + } 26 + 27 + .extra { 28 + padding: 2px; 29 + border-radius: 4px; 30 + 31 + background: light-dark( 32 + color-mix(in srgb, var(--theme-bg), black 10%), 33 + color-mix(in srgb, var(--theme-bg), white 10%) 34 + ); 35 + } 36 + }
+34
src/app/components/badge.tsx
··· 1 + import { ComponentProps, For, JSXElement, Show, splitProps } from "solid-js"; 2 + 3 + import styles from './badge.module.css'; 4 + 5 + export type BadgeProps = ComponentProps<'span'> & { 6 + label?: string 7 + value: JSXElement | JSXElement[] 8 + } 9 + 10 + export function Badge(props: BadgeProps) { 11 + const [badgeprops, spanprops] = splitProps(props, ['label', 'value']) 12 + 13 + const values = () => Array.isArray(badgeprops.value) ? badgeprops.value : [badgeprops.value] 14 + const primaryValue = () => values()[0] 15 + const secondaryValues = () => values().slice(1) 16 + 17 + return ( 18 + <span {...spanprops} class={styles['badge']}> 19 + <Show when={badgeprops.label}> 20 + {(label) => <span class={styles['label']}>{label()}</span>} 21 + </Show> 22 + <Show when={primaryValue()}> 23 + {(value) => <span class={styles['value']}>{value()}</span>} 24 + </Show> 25 + <Show when={secondaryValues()}> 26 + {(values) => ( 27 + <For each={values()}> 28 + {(value) => <span class={styles['extra']}>{value}</span>} 29 + </For> 30 + )} 31 + </Show> 32 + </span> 33 + ) 34 + }
+1 -1
src/app/layout/index.module.css
··· 24 24 25 25 @media (--tablet) { 26 26 grid-template-areas: 27 - 'header content' 27 + 'header header' 28 28 'sidebar content' 29 29 'sidebar drawer'; 30 30
+48
src/app/layout/sidenav/feed-row.tsx
··· 1 + import {A} from '@solidjs/router' 2 + import {ComponentProps, Show, createMemo, mergeProps, splitProps} from 'solid-js' 3 + 4 + import {Feed} from '#feedline/schema/feed' 5 + 6 + import { useFeedlineDatabase, useFeedlineDispatch } from '#app/context/feedline' 7 + import { makeSignalQuery } from '#app/primitives/database' 8 + import { Badge } from '#app/components/badge.jsx' 9 + import styles from './index.module.css' 10 + 11 + export type FeedRowProps = ComponentProps<'li'> & { 12 + feed: Feed 13 + } 14 + 15 + export function FeedRow(props: FeedRowProps) { 16 + const [feedprops, liprops] = splitProps(props, ['feed']); 17 + 18 + const feedline = useFeedlineDatabase() 19 + const dispatcher = useFeedlineDispatch() 20 + 21 + const feedurl = createMemo(() => encodeURIComponent(feedprops.feed.url)) 22 + 23 + const totalCount = makeSignalQuery(async () => 24 + await feedline.entries.where('feedurl').equals(feedprops.feed.url).count() 25 + ) 26 + 27 + const unreadCount = makeSignalQuery(async () => 28 + await feedline.entries.where('[feedurl+idx_read]').equals([feedprops.feed.url, 0]).count() 29 + ) 30 + 31 + return ( 32 + <li {...liprops}> 33 + <Show when={feedprops.feed.imageUrl} fallback={<span class={styles['placeholder']}></span>}> 34 + {(url) => ( 35 + <img 36 + src={url()} 37 + style={{height: '24px'}} 38 + alt={feedprops.feed.imageLabel ?? feedprops.feed.title ?? feedprops.feed.url} 39 + /> 40 + )} 41 + </Show> 42 + <A href={`/feeds/${feedurl()}`}> 43 + {feedprops.feed.title || feedprops.feed.url} 44 + </A> 45 + <Badge value={[unreadCount(), totalCount()]} /> 46 + </li> 47 + ) 48 + }
+47 -8
src/app/layout/sidenav/index.module.css
··· 1 1 .sidenav { 2 - display: flex; 3 - flex-direction: column; 2 + display: flex; 3 + flex-direction: column; 4 4 5 - .sidenav-section { 6 - flex: 1 1; 5 + ul.sidenav-section { 6 + flex: 1 1; 7 + overflow-y: auto; 8 + padding: 1rem; 9 + 10 + list-style: none; 11 + } 12 + 13 + .sidenav-bottom { 14 + flex: 0 0; 15 + align-self: flex-end; 16 + } 17 + } 18 + 19 + .sidenav-folder { 20 + h3 { 21 + margin: 1rem 1rem 1rem 0; 22 + padding: 0; 7 23 } 24 + } 8 25 9 - .sidenav-bottom { 10 - flex: 0 0; 11 - align-self: flex-end; 12 - } 26 + .sidenav-feed { 27 + display: flex; 28 + align-items: center; 29 + 30 + white-space: nowrap; 31 + margin: 8px 0; 32 + gap: 8px; 33 + 34 + > img, > .placeholder { 35 + flex: 0; 36 + display: inline-block; 37 + min-width: 24px; 38 + max-height: 24px; 39 + margin-right: 4px; 40 + } 41 + 42 + > a { 43 + flex: 1; 44 + min-width: 0; 45 + overflow: hidden; 46 + text-overflow: ellipsis; 47 + } 48 + 49 + > span { 50 + flex: 0; 51 + } 13 52 }
+53 -47
src/app/layout/sidenav/index.tsx
··· 1 + import {For, Show, createSignal} from 'solid-js' 2 + 1 3 import {useFeedlineDatabase, useFeedlineDispatch} from '#app/context/feedline' 2 - import {DebugNukeButton} from '#app/debug/nuke-database.button.jsx' 4 + import {DebugNukeButton} from '#app/debug/nuke-database.button' 3 5 import {makeSignalQuery, makeStoreQuery} from '#app/primitives/database' 4 - import {createDate, createTimeAgo} from '@solid-primitives/date' 5 - import {createEventDispatcher} from '@solid-primitives/event-dispatcher' 6 - import {A} from '@solidjs/router' 7 - import {For, createMemo, createSignal} from 'solid-js' 8 6 9 - import {Feed} from '#feedline/schema/feed.js' 7 + import { Feed } from '#feedline/schema/feed.js' 8 + import { StrictMap } from '#lib/strict-map.js' 10 9 10 + import {FeedRow} from './feed-row' 11 11 import styles from './index.module.css' 12 + import { useBreakpoints } from '#app/primitives/breakpoints.js' 13 + import { A } from '@solidjs/router' 12 14 13 15 export default function SideNav() { 14 16 const dispatch = useFeedlineDispatch() 15 17 const feedline = useFeedlineDatabase() 18 + const bp = useBreakpoints() 16 19 17 - const feeds = makeStoreQuery(() => feedline.feeds.reverse().toArray()) 18 20 const [newFeed, setNewFeed] = createSignal<string>('') 19 21 20 22 const addFeed = () => { 21 23 const newFeedUrl = newFeed() 22 24 if (newFeedUrl === '') return 23 25 24 - dispatch.addFeed(newFeedUrl, true) 26 + dispatch.addFeed(newFeedUrl) 25 27 setNewFeed('') 26 28 } 27 29 28 - const refreshFeed = (e: CustomEvent<string>) => { 29 - dispatch.refreshFeed(e.detail) 30 + const importOpml = (e: Event) => { 31 + const target = e.target as HTMLInputElement 32 + const file = target.files?.[0] 33 + if (file) { 34 + dispatch.importOpml(file) 35 + target.value = '' 36 + } 37 + } 38 + 39 + const folders = makeSignalQuery<Map<string, Feed[]>>(async () => { 40 + const results = new StrictMap<string, Feed[]>(); 41 + await feedline.feeds.where('idx_tags').startsWith('folder:').each((feed, cursor) => { 42 + results.update(cursor.key as string, (orig) => orig ? [...orig, feed] : [feed]) 43 + }) 44 + 45 + return results 46 + }) 47 + 48 + const folderTagDisplay = (folderTag: string) => { 49 + return folderTag.slice("folder:/".length); 30 50 } 31 51 32 52 return ( 33 53 <aside class={styles['sidenav']}> 34 - <div class={styles['sidenav-section']}> 35 - <h2>Feeds</h2> 36 - <ul> 37 - <For each={feeds}>{(feed) => <FeedRow feed={feed} onRefresh={refreshFeed} />}</For> 38 - </ul> 39 - </div> 54 + <Show when={bp.isPhone()}> 55 + <A href="/dashboard">Dashboard</A> 56 + </Show> 57 + <ul class={styles['sidenav-section']}> 58 + <Show when={folders()}> 59 + {(folders) => ( 60 + <For each={Array.from(folders().entries())}> 61 + {([path, feeds]) => ( 62 + <li class={styles['sidenav-folder']}> 63 + <h3>{folderTagDisplay(path)}</h3> 64 + <For each={feeds}>{(feed) => <FeedRow feed={feed} class={styles['sidenav-feed']} />}</For> 65 + </li> 66 + )} 67 + </For> 68 + )} 69 + </Show> 70 + </ul> 40 71 <div class={styles['sidenav-bottom']}> 41 72 <div> 42 73 <input ··· 48 79 <button onClick={addFeed}>Add Feed</button> 49 80 </div> 50 81 <br /> 82 + <div> 83 + <label> 84 + <input type="file" accept=".opml" onChange={importOpml} /> 85 + <span>Import OPML</span> 86 + </label> 87 + </div> 51 88 <DebugNukeButton /> 52 89 </div> 53 90 </aside> 54 91 ) 55 92 } 56 - 57 - type FeedRowProps = { 58 - feed: Feed 59 - onRefresh?: (evt: CustomEvent<string>) => void 60 - } 61 - 62 - function FeedRow(props: FeedRowProps) { 63 - const dispatch = createEventDispatcher(props) 64 - 65 - const feedurl = createMemo(() => encodeURIComponent(props.feed.url)) 66 - 67 - const feedline = useFeedlineDatabase() 68 - const [publishedAt] = createDate(props.feed.lastPublishedAt ?? props.feed.lastBuildAt ?? '1999-12-31') 69 - const [published] = createTimeAgo(publishedAt) 70 - 71 - const unreadCount = makeSignalQuery( 72 - async () => await feedline.entries.where('[feedurl+idx_read]').equals([props.feed.url, 0]).count(), 73 - ) 74 - 75 - return ( 76 - <li> 77 - <A href={`/feeds/${feedurl()}`}> 78 - <p>{props.feed.title || props.feed.url}</p> 79 - </A> 80 - <span>{props.feed.status}</span> 81 - <span>{published()}</span> 82 - <span>{unreadCount()}</span> 83 - <button onClick={() => dispatch('refresh', props.feed.url)}>Refresh</button> 84 - </li> 85 - ) 86 - }
+26 -44
src/app/pages/dashboard.tsx
··· 1 - import {FeedRow} from '#app/components/feed-row' 2 1 import {NavigationPage} from '#app/components/navigation-page' 3 2 import {useRealmIdentity} from '#app/context/realm-identity' 4 - import {DebugNukeButton} from '#app/debug/nuke-database.button' 5 - import {makeStoreQuery} from '#app/primitives/database' 6 - import {useFeedlineDatabase, useFeedlineDispatch} from '../context/feedline' 3 + import {makeSignalQuery} from '#app/primitives/database' 4 + import {useFeedlineDatabase} from '../context/feedline' 7 5 import {makeEventListenerStack} from '@solid-primitives/event-listener' 8 6 import {For, createResource, createSignal, onCleanup} from 'solid-js' 9 7 10 8 import {RealmPeer} from '#realm/client/peer' 11 9 import {IdentID} from '#realm/schema/brands' 10 + import { StrictMap } from '#lib/strict-map.js' 12 11 13 12 function PeerLabel(props: {identid: IdentID; peer: RealmPeer}) { 14 13 const [stats, {refetch}] = createResource(() => props.peer.connectionStats()) ··· 51 50 52 51 export default function Dashboard() { 53 52 const identity = useRealmIdentity() 54 - const dispatch = useFeedlineDispatch() 55 53 const feedline = useFeedlineDatabase() 54 + const peers = makeRealmPeersSignal() 55 + 56 + const tags = makeSignalQuery(async () => { 57 + const entries = await feedline.entries.toArray() 58 + const output = new StrictMap<string, number>(); 56 59 57 - const feeds = makeStoreQuery(() => feedline.feeds.reverse().toArray()) 58 - const peers = makeRealmPeersSignal() 60 + entries.forEach((entry) => { 61 + entry.idx_tags.forEach((tagstr) => { 62 + output.update(tagstr, (x => x === undefined ? 1 : x + 1)) 63 + }) 64 + }) 59 65 60 - const [invite, setInvite] = createSignal<string>('') 61 - const [newFeed, setNewFeed] = createSignal<string>('') 66 + return output 67 + }) 62 68 63 - let opmlInputRef: HTMLInputElement | undefined 69 + const topTags = () => { 70 + return Array 71 + .from(tags()?.entries() || []) 72 + .toSorted(([_b, acount], [_a, bcount]) => bcount - acount) 73 + .slice(0, 5) 74 + .reverse() 75 + } 76 + 77 + const [invite, setInvite] = createSignal<string>('') 64 78 65 79 const host = window.location.host 66 80 const scheme = window.location.protocol === 'https:' ? 'wss' : 'ws' ··· 86 100 }) 87 101 } 88 102 89 - const addFeed = () => { 90 - const newFeedUrl = newFeed() 91 - if (newFeedUrl !== '') { 92 - dispatch.addFeed(newFeedUrl, true) 93 - setNewFeed('') 94 - } 95 - } 96 - 97 - const handleOpmlImport = (e: Event) => { 98 - const file = (e.target as HTMLInputElement).files?.[0] 99 - if (file) { 100 - dispatch.importOpml(file) 101 - if (opmlInputRef) opmlInputRef.value = '' 102 - } 103 - } 104 - 105 103 return ( 106 104 <NavigationPage> 107 - <h2>Feeds</h2> 108 - <ul> 109 - <li> 110 - <input 111 - type="url" 112 - placeholder="http://example.com" 113 - value={newFeed()} 114 - onChange={(e) => setNewFeed(e.target.value)} 115 - /> 116 - <button onClick={addFeed}>Add Feed</button> 117 - <input ref={opmlInputRef} type="file" accept=".opml,.xml" onChange={handleOpmlImport} /> 118 - <button onClick={() => opmlInputRef?.click()}>Import OPML</button> 119 - </li> 120 - <For each={feeds}>{(feed) => <FeedRow feed={feed} />}</For> 121 - </ul> 105 + <h2>Tags</h2> 106 + <pre>{JSON.stringify(topTags(), null, 2)}</pre> 122 107 <h2>Peers</h2> 123 108 <ul> 124 109 <For each={peers()}>{(peer) => <PeerLabel identid={peer.identid} peer={peer} />}</For> ··· 129 114 <button onClick={exchangeInvite}>Exchange Invitation</button> 130 115 <button onClick={registerAction}>Register</button> 131 116 <button onClick={generateInvite}>Generate Invitation</button> 132 - <br /> 133 - <br /> 134 - <DebugNukeButton /> 135 117 </NavigationPage> 136 118 ) 137 119 }
+1 -1
src/app/styles/system.css
··· 6 6 --safe-right: env(safe-area-inset-right, 0px); 7 7 8 8 --layout-header-height: 56px; 9 - --layout-sidebar-width: 280px; 9 + --layout-sidebar-width: 360px; 10 10 --layout-drawer-mini: 64px; 11 11 --layout-drawer-midi: 40dvh; 12 12 }
+2 -1
src/feedline/client/action-dispatcher.ts
··· 23 23 this.#abort.abort(new Error('dispatcher shutting down')) 24 24 } 25 25 26 - addFeed(url: string, local = true) { 26 + addFeed(url: string, path: string[] = [], local = true) { 27 27 void this.#identity.dispatchAction<FeedAddAction>('feed:add', { 28 28 url, 29 + tags: [{ tag: 'folder', value: path.join('/') }], 29 30 local, 30 31 lock: { 31 32 by: this.#identity.identid,
+37 -3
src/feedline/client/action-handler.ts
··· 109 109 interval: 'daily', 110 110 }, 111 111 links: [], 112 - tags: [], 112 + tags: action.dat.tags ?? [], 113 113 meta: {}, 114 114 }) 115 115 ··· 125 125 await this.#db.feeds.delete(action.dat.url) 126 126 } 127 127 128 + #deepMerge(...objects: Record<string, unknown>[]): Record<string, unknown> { 129 + const isObject = (obj: any) => obj && typeof obj === 'object'; 130 + 131 + function deepMergeInner(target: Record<string, unknown>, source: Record<string, unknown>) { 132 + Object.keys(source).forEach((key: string) => { 133 + const sourceValue = source[key]; 134 + const targetValue = target[key]; 135 + 136 + if (Array.isArray(targetValue) && Array.isArray(sourceValue)) { 137 + target[key] = targetValue.concat(sourceValue); 138 + } else if (isObject(targetValue) && isObject(sourceValue)) { 139 + target[key] = deepMergeInner(Object.assign({}, targetValue), sourceValue); 140 + } else { 141 + target[key] = sourceValue; 142 + } 143 + }); 144 + 145 + return target; 146 + } 147 + 148 + if (objects.length < 2) { 149 + throw new Error('deepMerge: this function expects at least 2 objects to be provided'); 150 + } 151 + 152 + if (objects.some(object => !isObject(object))) { 153 + throw new Error('deepMerge: all values should be of type "object"'); 154 + } 155 + 156 + return objects.reduce(deepMergeInner) 157 + } 158 + 128 159 async #feedPatch(action: FeedPatchAction) { 129 160 // 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) 161 + await this.#db.feeds.update(action.dat.url, ((value, ref) => { 162 + const update = this.#db.enrichFeed(action.dat.payload) 163 + ref.value = this.#deepMerge(value, update) 164 + console.log('patched to:', ref.value) 165 + })) 132 166 } 133 167 134 168 async #entryPatch(action: EntryPatchAction) {
+22 -10
src/feedline/client/opml-importer.ts
··· 1 1 import {parseOpml as parseOpmlFeedsmith} from 'feedsmith' 2 - import type {DeepPartial, Opml} from 'feedsmith/types' 3 - 2 + import type { DeepPartial, Opml } from 'feedsmith/types' 4 3 import {LogicalClock} from '#realm/logical-clock' 5 - 6 4 import {FeedAddAction} from '#feedline/schema/actions-feed' 5 + import { Tag } from '#feedline/schema/feed.js' 7 6 8 7 export class OpmlImporter { 9 8 #clock: LogicalClock ··· 16 15 const text = await file.text() 17 16 const urls = parseOpml(text) 18 17 19 - for (const url of urls) { 18 + for (const [path, url] of urls) { 20 19 const action: FeedAddAction = { 21 20 typ: 'act', 22 21 clk: this.#clock.now(), 23 22 msg: 'feed:add', 24 23 dat: { 25 24 url, 25 + tags: [{ tag: 'folder', value: path.join('/') }], 26 26 local: true, 27 27 }, 28 28 } ··· 32 32 } 33 33 } 34 34 35 - function parseOpml(text: string): string[] { 36 - const doc = parseOpmlFeedsmith(text) 37 - function reduce(memo: string[], outline: DeepPartial<Opml.Outline<string>>): string[] { 38 - const next = outline.xmlUrl ? [...memo, outline.xmlUrl] : memo 39 - return outline.outlines ? outline.outlines.reduce(reduce, next) : next 35 + type Node = DeepPartial<Opml.Outline<string>>; 36 + 37 + // returns [path, url] where path should turn into a folder structure in tags 38 + function parseOpml(text: string): [string[], string][] { 39 + function reduce(memo: [string[], string][], item: [string[], Node]): [string[], string][] { 40 + const [path, outline] = item; 41 + 42 + const value: [string[], string][] = outline.xmlUrl ? [...memo, [path, outline.xmlUrl]] : memo 43 + if (!outline.outlines) { 44 + return value 45 + } else { 46 + return outline.outlines 47 + .map<[string[], Node]>((o) => [outline.title ? [...path, outline.title] : path, o]) 48 + .reduce(reduce, value) 49 + } 40 50 } 41 51 42 - return doc.body?.outlines?.reduce(reduce, [] as string[]) ?? [] 52 + const doc = parseOpmlFeedsmith(text) 53 + const outlines: Node[] | undefined = doc?.body?.outlines 54 + return outlines?.map<[string[], Node]>((outline) => [[], outline]).reduce(reduce, []) ?? [] 43 55 }
+2 -1
src/feedline/schema/actions-feed.ts
··· 2 2 3 3 import {makeActionSchema} from '#realm/schema' 4 4 5 - import {feedSchema} from './feed' 5 + import {feedSchema, tagSchema} from './feed' 6 6 import {lockSchema} from './lock' 7 7 8 8 export const feedAddSchema = makeActionSchema( 9 9 'feed:add', 10 10 z.object({ 11 11 url: z.url(), 12 + tags: z.array(tagSchema).optional(), 12 13 lock: lockSchema.optional(), 13 14 local: z.boolean().optional(), // don't publish to ATProto 14 15 refreshInterval: z.number().optional(), // seconds, defaults to 3600 (1 hour)
+2
src/feedline/schema/feed.ts
··· 13 13 14 14 export const tagSchema = z.discriminatedUnion('tag', [ 15 15 z.object({tag: z.literal('explicit')}), 16 + z.object({tag: z.literal('folder'), value: z.string()}), // `/` separated folder names (spaces ok) 16 17 z.object({tag: z.literal('category'), value: z.string()}), 17 18 z.object({tag: z.literal('language'), value: z.string()}), // ISO code 18 19 z.object({ ··· 34 35 case 'person': return `person:${tag.value}${tag.rel ? ":" + tag.rel : ""}` 35 36 case 'podcast.season': return `podcast:season:${tag.value}` 36 37 case 'podcast.episode': return `podcast:episode:${tag.value}` 38 + case 'folder': return `folder:/${tag.value}` 37 39 } 38 40 } 39 41