Skip to content

Commit fc63c46

Browse files
Add Feed app — fediverse-style timeline (3 apps total)
A third app at solid-apps.github.io/feed/. Renders an OrderedCollection of Notes as a vertical feed of cards. Companion to the Profile app — Profile is your card, Feed is your stream. feed/note-feed-pane.js (~200 lines): - canHandle: any Collection/OrderedCollection - Reads orderedItems (or items) array, sorts newest-first by published - Per-card: author chip (avatar + name + @nick + relative time), optional content warning (yellow accent, like fediverse spoiler tags), body text (whitespace preserved), hashtag pills - Author from inline attributedTo Person object — full rich display without fetching. Pod-backed apps would resolve string @id refs lazily. - Relative time: "just now" / "Nm ago" / "Nh ago" / "Nd ago" / "Apr 19" - Card hover: subtle lift + border accent - Avatar fallback: gradient initial when img missing or fails Sample data: 5 notes by Tim, Melvin, Ruben, Alice, Bob — mix of plain posts, hashtags, and one with a content warning. Spread over ~18h of realistic timestamps. xlogin wired. autoSchema runs (no-op since OrderedCollection isn't yet a solid-panes manifest entry — could add to enable schema-pane Edit and schema-view View on the collection itself, but the bespoke feed pane is the real thing here). The cross-app interop story now has its visual proof: open Profile to see Tim's card, open Feed to see Tim + others' posts. Same urn:solid types, different lens, different URL — but the data shape is the contract.
1 parent 3ce8d18 commit fc63c46

6 files changed

Lines changed: 371 additions & 0 deletions

File tree

corpus.jsonl

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
1+
{"name":"Feed","description":"A fediverse-style timeline. Renders an OrderedCollection of Notes as cards with author chips, relative timestamps, content warnings, and hashtags. Companion to the Profile app.","entry":"./index.html","types":["urn:solid:OrderedCollection","urn:solid:Note"],"icon":"📜","author":"Melvin Carvalho","status":"stable","added":"2026-04-19"}
12
{"name":"Profile","description":"Your card on the web. Beautiful schema-driven Person page with bespoke Profile view, schema-driven Edit, and Source. Open ?uri=<your-webid> to view any Solid profile.","entry":"./index.html","types":["urn:solid:Person"],"icon":"👤","author":"Melvin Carvalho","status":"stable","added":"2026-04-19"}
23
{"name":"Todos","description":"A list of tasks. Built on LOSOS's bespoke todo-pane via the urn:solid:Tracker manifest.","entry":"./index.html","types":["urn:solid:Tracker","urn:solid:Vtodo"],"icon":"","author":"Melvin Carvalho","status":"stable","added":"2026-04-19"}

feed/app.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"name": "Feed",
3+
"description": "A fediverse-style timeline. Renders an OrderedCollection of Notes as cards with author chips, relative timestamps, content warnings, and hashtags. Companion to the Profile app.",
4+
"entry": "./index.html",
5+
"types": ["urn:solid:OrderedCollection", "urn:solid:Note"],
6+
"icon": "\ud83d\udcdc",
7+
"author": "Melvin Carvalho",
8+
"status": "stable",
9+
"added": "2026-04-19"
10+
}

feed/index.html

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="utf-8">
5+
<meta name="viewport" content="width=device-width, initial-scale=1">
6+
<title>Feed — solid-apps</title>
7+
<style>
8+
* { margin: 0; box-sizing: border-box; }
9+
body { font-family: 'Inter', -apple-system, sans-serif; background: #fafaf8; }
10+
#losos { max-width: 100% !important; }
11+
#losos > div { max-width: 100% !important; width: 100% !important; }
12+
</style>
13+
</head>
14+
<body>
15+
16+
<!-- Sample feed: an OrderedCollection of Notes from a few well-known WebIDs.
17+
attributedTo is inline (Person object) so the feed pane gets author info
18+
without fetching. In a real pod-backed app, attributedTo would be just an
19+
@id and the pane could choose to lazy-fetch + cache. -->
20+
<script type="application/ld+json">
21+
{
22+
"@context": { "@vocab": "urn:solid:" },
23+
"@id": "#this",
24+
"@type": "OrderedCollection",
25+
"title": "Public timeline",
26+
"totalItems": 5,
27+
"orderedItems": [
28+
{
29+
"@id": "#5",
30+
"@type": "Note",
31+
"attributedTo": {
32+
"@id": "https://www.w3.org/People/Berners-Lee/card#i",
33+
"name": "Sir Tim Berners-Lee",
34+
"nick": "timbl",
35+
"img": "https://www.w3.org/People/Berners-Lee/Press/stock-photos/tim-berners-lee-inrupt-2024.jpg"
36+
},
37+
"content": "The Web is for everyone. Solid puts data back in your pod, where you control it. Build apps that read and write to that pod, and the web becomes interoperable again.",
38+
"published": "2026-04-19T16:32:00Z",
39+
"tag": ["#solid", "#web"]
40+
},
41+
{
42+
"@id": "#4",
43+
"@type": "Note",
44+
"attributedTo": {
45+
"@id": "https://melvincarvalho.com/#me",
46+
"name": "Melvin Carvalho",
47+
"nick": "melvincarvalho"
48+
},
49+
"content": "Just shipped: LION + urn-solid + solid-schema + solid-panes + LOSOS + xlogin. Five layers, six repos, one Profile app. Open https://solid-apps.github.io/profile/ to see your card on the web.",
50+
"published": "2026-04-19T15:10:00Z",
51+
"tag": ["#solid", "#urn-solid", "#losos"]
52+
},
53+
{
54+
"@id": "#3",
55+
"@type": "Note",
56+
"attributedTo": {
57+
"@id": "https://ruben.verborgh.org/profile/#me",
58+
"name": "Ruben Verborgh",
59+
"nick": "rubenverborgh"
60+
},
61+
"content": "Decentralisation isn't a feature. It's a property of the network — emerging from many small, independent actors choosing the same protocols. Vocabulary is part of the protocol.",
62+
"published": "2026-04-19T11:45:00Z"
63+
},
64+
{
65+
"@id": "#2",
66+
"@type": "Note",
67+
"attributedTo": {
68+
"@id": "https://alice.example.org/profile/card#me",
69+
"name": "Alice",
70+
"nick": "alice"
71+
},
72+
"summary": "spoilers for the urn:solid stack reveal",
73+
"content": "I cannot believe what just happened — you can write JSON, get RDF for free, drop in a one-line login, and your app reads other apps' data through a shared registry. It just works.",
74+
"published": "2026-04-19T09:18:00Z"
75+
},
76+
{
77+
"@id": "#1",
78+
"@type": "Note",
79+
"attributedTo": {
80+
"@id": "https://bob.example.org/profile/card#me",
81+
"name": "Bob",
82+
"nick": "bob"
83+
},
84+
"content": "Hello fediverse. First note from my pod.",
85+
"published": "2026-04-18T22:01:00Z",
86+
"tag": ["#firstpost"]
87+
}
88+
]
89+
}
90+
</script>
91+
92+
<!-- Login (Nostr + Solid). -->
93+
<script src="https://unpkg.com/xlogin"></script>
94+
95+
<!-- Panes: Feed (custom) first, then Source. No schema-pane/schema-view here —
96+
OrderedCollection isn't yet in solid-schema/solid-panes registries (could be added). -->
97+
<script type="module" data-pane src="./note-feed-pane.js"></script>
98+
<script type="module" data-pane src="https://losos.org/panes/source-pane.js"></script>
99+
100+
<div id="losos"></div>
101+
102+
<script type="module">
103+
import { autoSchema } from 'https://solid-panes.github.io/auto-schema.js'
104+
const result = await autoSchema()
105+
console.log('[feed] autoSchema patched', result.patched, 'island(s),', result.skipped, 'skipped')
106+
await import('https://losos.org/losos/shell.js')
107+
</script>
108+
109+
</body>
110+
</html>

feed/note-feed-pane.js

Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
/**
2+
* Note feed pane — renders an OrderedCollection of Notes as a fediverse-style feed.
3+
*
4+
* Each Note becomes a card: author chip (from inline attributedTo or the WebID),
5+
* relative timestamp, optional content warning (summary), body, hashtags row.
6+
*
7+
* Companion to the Profile pane: Profile is your card, Feed is your stream.
8+
*
9+
* AGPL-3.0 — part of solid-apps
10+
*/
11+
12+
import { html, render } from 'https://losos.org/losos/html.js'
13+
14+
const isCollectionType = (t) => {
15+
if (!t) return false
16+
const s = Array.isArray(t) ? t.join(' ') : t
17+
return /OrderedCollection|Collection/i.test(s)
18+
}
19+
20+
const niceHost = (u) => {
21+
try { return new URL(u).host } catch { return u }
22+
}
23+
24+
// Author display: prefer inline Person object, else strip the WebID.
25+
function authorOf(attr) {
26+
if (!attr) return null
27+
if (typeof attr === 'string') {
28+
return { name: niceHost(attr), nick: null, ref: attr, img: null }
29+
}
30+
if (typeof attr === 'object') {
31+
return {
32+
name: attr.name || attr.nick || (attr['@id'] && niceHost(attr['@id'])) || 'Unknown',
33+
nick: attr.nick,
34+
ref: attr['@id'] || null,
35+
img: attr.img || null
36+
}
37+
}
38+
return null
39+
}
40+
41+
// "2h ago" / "3d ago" / "Apr 19" — soft, fediverse-ish
42+
function relativeTime(iso) {
43+
if (!iso) return ''
44+
let d
45+
try { d = new Date(iso) } catch { return iso }
46+
if (isNaN(d)) return iso
47+
const now = Date.now()
48+
const diff = (now - d.getTime()) / 1000
49+
if (diff < 60) return 'just now'
50+
if (diff < 3600) return Math.floor(diff / 60) + 'm ago'
51+
if (diff < 86400) return Math.floor(diff / 3600) + 'h ago'
52+
if (diff < 86400 * 7) return Math.floor(diff / 86400) + 'd ago'
53+
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
54+
}
55+
56+
const initial = (s) => (s || '?').trim().charAt(0).toUpperCase()
57+
58+
export default {
59+
label: 'Feed',
60+
icon: '\ud83d\udcdc',
61+
62+
canHandle(subject, store) {
63+
const node = store.get(subject.value)
64+
if (!node) return false
65+
return isCollectionType(store.type(node))
66+
},
67+
68+
render(subject, lionStore, container, rawData) {
69+
let data = rawData
70+
if (!data) {
71+
const dataEl = document.querySelector('script[type="application/ld+json"]')
72+
try { data = JSON.parse(dataEl.textContent) } catch { return }
73+
}
74+
75+
const items = data.orderedItems || data.items || []
76+
const title = data.title || data.name || 'Feed'
77+
const total = data.totalItems != null ? data.totalItems : items.length
78+
79+
// Sort newest-first if published is set
80+
const sorted = items.slice().sort(function(a, b) {
81+
const pa = (a && a.published) || ''
82+
const pb = (b && b.published) || ''
83+
return pb > pa ? 1 : pb < pa ? -1 : 0
84+
})
85+
86+
const renderTags = function(tags) {
87+
if (!Array.isArray(tags) || tags.length === 0) return null
88+
return html`<div class="nf-tags">${tags.map(function(t) {
89+
let label = null, href = null
90+
if (typeof t === 'string') { label = t.startsWith('#') ? t : '#' + t }
91+
else if (t && typeof t === 'object') { label = t.name || t['@id']; href = t.href || t['@id'] }
92+
if (!label) return null
93+
return href
94+
? html`<a class="nf-tag" href="${href}" target="_blank" rel="noopener">${label}</a>`
95+
: html`<span class="nf-tag">${label}</span>`
96+
})}</div>`
97+
}
98+
99+
const renderCard = function(note) {
100+
const a = authorOf(note.attributedTo)
101+
const time = note.published ? relativeTime(note.published) : null
102+
const summary = note.summary
103+
const content = note.content
104+
const tags = note.tag
105+
106+
return html`
107+
<article class="nf-card">
108+
<header class="nf-head">
109+
${a && a.img
110+
? html`<img class="nf-ava" src="${a.img}" alt="${a.name}" onerror="${function(e) { const f = document.createElement('div'); f.className = 'nf-ava-fallback'; f.textContent = initial(a.name); e.target.replaceWith(f) }}" />`
111+
: a
112+
? html`<div class="nf-ava-fallback">${initial(a.name)}</div>`
113+
: html`<div class="nf-ava-fallback">?</div>`
114+
}
115+
<div class="nf-meta">
116+
${a && a.ref
117+
? html`<a class="nf-author" href="${a.ref}" target="_blank" rel="noopener">${a.name}</a>`
118+
: html`<span class="nf-author">${(a && a.name) || 'Unknown'}</span>`
119+
}
120+
${a && a.nick ? html`<span class="nf-nick">@${a.nick}</span>` : null}
121+
${time ? html`<span class="nf-dot">·</span><span class="nf-time" title="${note.published || ''}">${time}</span>` : null}
122+
</div>
123+
</header>
124+
${summary ? html`<div class="nf-cw">${summary}</div>` : null}
125+
${content ? html`<div class="nf-body">${content}</div>` : null}
126+
${renderTags(tags)}
127+
</article>
128+
`
129+
}
130+
131+
render(container, html`
132+
<style>
133+
.nf-bg {
134+
min-height: 100vh;
135+
background: linear-gradient(180deg, #fafaf8 0%, #f4f3ef 100%);
136+
padding: 56px 20px 96px;
137+
}
138+
.nf-wrap { max-width: 640px; margin: 0 auto; }
139+
.nf-title {
140+
font: italic 400 48px/1.05 Georgia, serif;
141+
color: #1a1a1a; margin: 0 0 6px; letter-spacing: -0.8px;
142+
}
143+
.nf-sub {
144+
font: 500 13px/1 'Inter', -apple-system, sans-serif;
145+
color: #999; margin: 0 0 40px;
146+
letter-spacing: 0.05em; text-transform: uppercase;
147+
}
148+
.nf-card {
149+
background: #fff;
150+
border: 1px solid #ece9e2;
151+
border-radius: 12px;
152+
padding: 20px 22px 22px;
153+
margin: 0 0 16px;
154+
transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease;
155+
}
156+
.nf-card:hover {
157+
transform: translateY(-1px);
158+
box-shadow: 0 6px 24px rgba(0,0,0,0.06);
159+
border-color: #d9d6cf;
160+
}
161+
.nf-head {
162+
display: flex; align-items: center; gap: 12px;
163+
margin-bottom: 12px;
164+
}
165+
.nf-ava, .nf-ava-fallback {
166+
width: 40px; height: 40px; border-radius: 50%;
167+
flex-shrink: 0;
168+
}
169+
.nf-ava { object-fit: cover; }
170+
.nf-ava-fallback {
171+
background: linear-gradient(135deg, #6366f1, #8b5cf6);
172+
color: #fff; display: flex; align-items: center; justify-content: center;
173+
font: 600 16px/1 Georgia, serif;
174+
}
175+
.nf-meta {
176+
display: flex; align-items: baseline; gap: 8px;
177+
flex-wrap: wrap; min-width: 0;
178+
}
179+
.nf-author {
180+
font: 600 15px/1.3 'Inter', -apple-system, sans-serif;
181+
color: #1a1a1a; text-decoration: none;
182+
}
183+
.nf-author:hover { color: #6366f1; }
184+
.nf-nick {
185+
font: 400 13px/1.3 ui-monospace, SFMono-Regular, monospace;
186+
color: #999;
187+
}
188+
.nf-dot { color: #ccc; font-weight: 400; }
189+
.nf-time {
190+
font: 400 13px/1.3 'Inter', -apple-system, sans-serif;
191+
color: #999;
192+
}
193+
.nf-cw {
194+
font: 500 13px/1.5 'Inter', -apple-system, sans-serif;
195+
color: #888; padding: 8px 12px; margin: 0 0 10px;
196+
background: #f8f7f3; border-left: 3px solid #d4af37;
197+
border-radius: 4px;
198+
}
199+
.nf-body {
200+
font: 400 16px/1.6 'Inter', -apple-system, sans-serif;
201+
color: #1a1a1a; white-space: pre-wrap; word-wrap: break-word;
202+
}
203+
.nf-tags {
204+
margin-top: 14px; display: flex; flex-wrap: wrap; gap: 6px;
205+
}
206+
.nf-tag {
207+
padding: 4px 10px; background: #f4f3ef;
208+
border-radius: 999px;
209+
font: 500 12px/1 'Inter', sans-serif;
210+
color: #6366f1; text-decoration: none;
211+
}
212+
.nf-tag:hover { background: #ece9e2; }
213+
.nf-empty {
214+
padding: 80px 20px; text-align: center; color: #999;
215+
font: 400 15px/1.5 'Inter', sans-serif;
216+
}
217+
</style>
218+
219+
<div class="nf-bg">
220+
<div class="nf-wrap">
221+
<h1 class="nf-title">${title}</h1>
222+
<div class="nf-sub">${total} ${total === 1 ? 'note' : 'notes'}</div>
223+
224+
${sorted.length === 0
225+
? html`<div class="nf-empty">No notes yet.</div>`
226+
: sorted.map(renderCard)
227+
}
228+
</div>
229+
</div>
230+
`)
231+
}
232+
}

index.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,16 @@
11
{
2+
"feed": {
3+
"name": "Feed",
4+
"description": "A fediverse-style timeline. Renders an OrderedCollection of Notes as cards with author chips, relative timestamps, content warnings, and hashtags. Companion to the Profile app.",
5+
"entry": "/feed/index.html",
6+
"types": [
7+
"urn:solid:OrderedCollection",
8+
"urn:solid:Note"
9+
],
10+
"icon": "📜",
11+
"status": "stable",
12+
"manifest": "/feed/app.json"
13+
},
214
"profile": {
315
"name": "Profile",
416
"description": "Your card on the web. Beautiful schema-driven Person page with bespoke Profile view, schema-driven Edit, and Source. Open ?uri=<your-webid> to view any Solid profile.",

reverse-index.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
11
{
2+
"urn:solid:OrderedCollection": [
3+
"/feed/"
4+
],
5+
"urn:solid:Note": [
6+
"/feed/"
7+
],
28
"urn:solid:Person": [
39
"/profile/"
410
],

0 commit comments

Comments
 (0)