Pin a feed to the top of the feed list

subscriptions.pinned, per person, set by PATCH /api/feeds/{id} {pinned} and
returned as FeedRow.pinned. Kept out of Sub, which the scanner merges into its
policy; set_subscription names its columns, so saving a feed's settings leaves
the pin alone (tested).

Pinned feeds come first in the list, a pin before the name and a rule under the
block: a pinned folder with its feeds under it, a feed from inside one lifted out
of it. The pin button is on both the feed and the folder page.

Closes #33.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-18 15:14:47 +00:00
parent fc425bffa6
commit 2d158a4540
7 changed files with 109 additions and 7 deletions

View File

@@ -19,6 +19,8 @@ The long form, with what was wrong before and how it was found, is in
- Your theme is kept on your account rather than in the browser, so it follows you to another
browser or computer, and the page arrives in it with no flash of the default. The theme a
browser already had is saved to your account the first time you load the page.
- Pin a feed to the top of the feed list with the pin on its page, a feed from inside an OPML or
Patreon folder included, which comes out of the folder while pinned. Pins are yours alone.
- Touch gestures: pull the item list down from its top to check the feed for new items, and
swipe the item you are reading left for the next one and right for the one before, or back
to the list from the first.

View File

@@ -96,6 +96,8 @@ CREATE TABLE IF NOT EXISTS subscriptions (
auto_download INTEGER,
allow_explicit INTEGER,
max_new_per_check INTEGER,
-- Pinned to the top of this person's feed list, a feed inside a folder included.
pinned INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (user_id, feed_id)
);
@@ -182,6 +184,7 @@ fn migrate(conn: &Connection) -> Result<()> {
// Kept per account so a theme follows you to another browser; it was in localStorage.
("users", "theme", "TEXT"),
("users", "theme_mode", "TEXT"),
("subscriptions", "pinned", "INTEGER NOT NULL DEFAULT 0"),
];
let retired: &[(&str, &str)] = &[
// Read state from before accounts, long since moved to entry_state. Two bugs came from
@@ -1060,6 +1063,24 @@ impl Db {
Ok(())
}
/// The feeds this person pinned to the top of their list. Kept apart from `Sub`, which is
/// what the scanner merges into its policy, and which a pin has nothing to do with.
pub fn pinned_feeds(&self, user_id: i64) -> Result<std::collections::HashSet<String>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare("SELECT feed_id FROM subscriptions WHERE user_id = ?1 AND pinned")?;
let ids = stmt.query_map([user_id], |r| r.get(0))?.collect::<rusqlite::Result<_>>()?;
Ok(ids)
}
/// False when they do not subscribe to it, since there is then no row in their list to pin.
pub fn set_pinned(&self, user_id: i64, feed_id: &str, on: bool) -> Result<bool> {
let conn = self.conn.lock().unwrap();
Ok(conn.execute(
"UPDATE subscriptions SET pinned = ?3 WHERE user_id = ?1 AND feed_id = ?2",
params![user_id, feed_id, on as i64],
)? > 0)
}
pub fn unsubscribe(&self, user_id: i64, feed_id: &str) -> Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute(
@@ -2029,6 +2050,21 @@ mod tests {
assert_eq!(after.error_since, None, "a clean check ends the run of failures");
}
#[test]
fn a_pin_survives_saving_the_feeds_settings_and_needs_a_subscription() {
let db = Db::memory().unwrap();
let me = db.create_user("pat", None, false).unwrap();
assert!(!db.set_pinned(me, "f", true).unwrap(), "not subscribed: nothing to pin");
db.subscribe(me, "f").unwrap();
assert!(db.set_pinned(me, "f", true).unwrap());
// set_subscription writes the rest of the row; it must leave the pin alone.
db.set_subscription(me, &Sub { feed_id: "f".into(), auto_download: Some(false), ..Default::default() })
.unwrap();
assert!(db.pinned_feeds(me).unwrap().contains("f"));
db.set_pinned(me, "f", false).unwrap();
assert!(db.pinned_feeds(me).unwrap().is_empty());
}
#[test]
fn enclosure_url_is_the_dedupe_key() {
let db = Db::memory().unwrap();

View File

@@ -614,6 +614,8 @@ struct FeedRow {
unread: i64,
/// Including you. More than one means every file here is shared.
subscribers: i64,
/// Pinned to the top of your list.
pinned: bool,
}
#[derive(Serialize)]
@@ -641,6 +643,7 @@ async fn feeds(
.map(|s| (s.feed_id.clone(), s))
.collect();
let counts = state.ctx.db.subscriber_counts()?;
let pinned = state.ctx.db.pinned_feeds(user.id)?;
let mut out = Vec::with_capacity(mine.len());
for sub in &subs {
let (id, feed) = (&sub.id, &sub.cfg);
@@ -701,6 +704,7 @@ async fn feeds(
downloaded: s.downloaded,
unread: state.ctx.db.unread_count(user.id, id)?,
subscribers: counts.get(id).copied().unwrap_or(0),
pinned: pinned.contains(id),
});
}
Ok(Json(out))
@@ -884,6 +888,13 @@ impl ApiError {
status: StatusCode::FORBIDDEN,
}
}
fn not_found(msg: impl Into<String>) -> Self {
Self {
error: anyhow::Error::msg(msg.into()),
status: StatusCode::NOT_FOUND,
}
}
}
impl IntoResponse for ApiError {
@@ -1218,6 +1229,7 @@ struct FeedPatch {
auto_download: Option<bool>,
#[serde(default, deserialize_with = "double_option")]
max_new_per_check: Option<Option<usize>>,
pinned: Option<bool>,
}
fn double_option<'de, T, D>(de: D) -> Result<Option<Option<T>>, D::Error>
@@ -1234,6 +1246,12 @@ async fn patch_feed(
user: crate::db::User,
Json(body): Json<FeedPatch>,
) -> Result<StatusCode, ApiError> {
// Pinning is yours alone too, and means nothing for a feed you do not subscribe to.
if let Some(on) = body.pinned
&& !state.ctx.db.set_pinned(user.id, &id, on)?
{
return Err(ApiError::not_found("you do not subscribe to that feed"));
}
// What one person wants -- which items, whether to fetch them, how many at a time --
// is theirs. It goes on their subscription and nobody else sees the change.
if state.ctx.db.subscription(user.id, &id)?.is_some() {

View File

@@ -1263,3 +1263,30 @@ test.describe('an item with no files, on a phone', () => {
await expect(page.locator('#detail')).not.toContainText('No files');
});
});
test('a pinned feed, even one from inside a folder, goes to the top of the list', async ({ page }) => {
const rows = page.locator('#feedlist .feed');
const group = page.locator('.feed.group').first();
if ((await group.locator('.chev').getAttribute('aria-expanded')) !== 'true') await group.locator('.chev').click();
const child = page.locator('.feed.child').first();
const name = (await child.locator('.txt b').textContent()).trim();
await child.click();
await page.locator('#content .acts [data-a="pin"]').click();
// First in the list, out of its folder, marked, and with the rule under it.
await expect(rows.first().locator('.txt b')).toHaveText(name);
await expect(rows.first()).toHaveClass(/\bpinned\b/);
await expect(rows.first()).not.toHaveClass(/\bchild\b/);
await expect(rows.first()).toHaveClass(/\blastpin\b/);
await expect(page.locator('.feed.child', { hasText: name })).toHaveCount(0);
await expect(page.locator('#content .acts [data-a="pin"]')).toHaveAttribute('aria-pressed', 'true');
// It is on the account: a reload keeps it.
await page.reload();
await expect(rows.first().locator('.txt b')).toHaveText(name);
// Unpinned, it goes back into its folder.
await rows.first().click();
await page.locator('#content .acts [data-a="pin"]').click();
await expect(page.locator('.feed.pinned')).toHaveCount(0);
await expect(page.locator('.feed.child', { hasText: name })).toHaveCount(1);
});

View File

@@ -363,6 +363,10 @@ input:focus,select:focus{outline:0;border-color:var(--accent)}
.feed.sel{background:var(--raise)}
/* A show sits under its folder's title, a size down, so an open folder reads as one. */
.feed.child{margin-left:11px}
/* Pinned feeds, at the top of the list: a small pin before the name, and a rule under the last. */
.feed .fpin .i{width:10px;height:10px;margin-right:5px;vertical-align:-1px;color:var(--accent)}
.feed.lastpin{margin-bottom:9px}
.feed.lastpin::after{content:"";position:absolute;left:8px;right:8px;bottom:-5px;border-bottom:1px solid var(--line)}
.feed.child .art{width:28px;height:28px;font-size:11px}
/* Only a folder has a triangle, hung in the margin so every feed's art lines up with the places
above it. The button is the row's full height and 24 px wide: a near miss used to open the

View File

@@ -30,6 +30,7 @@ function renderFeed(){
<button class="btn ico primary" data-a="scan" title="Check this feed now" aria-label="Check this feed now">${ICON.scan}</button>
<button class="btn ico" data-a="dl" title="Download latest…" aria-label="Download latest">${ICON.download}</button>
<button class="btn ico" data-a="read" title="Mark all read" aria-label="Mark all read">${ICON.checks}</button>
<button class="btn ico" data-a="pin" title="${f.pinned?'Unpin from the top of the feed list':'Pin to the top of the feed list'}" aria-label="${f.pinned?'Unpin':'Pin'}" aria-pressed="${!!f.pinned}">${f.pinned?ICON.pinOn:ICON.pin}</button>
<button class="btn ico" data-a="settings" title="Settings" aria-label="Settings">${ICON.settings}</button>
<button class="btn ico danger" data-a="rm" title="Unsubscribe" aria-label="Unsubscribe">${ICON.circleMinus}</button>
</div>
@@ -118,6 +119,7 @@ function renderGroup(f,kids){
<div class="acts">
<button class="btn ico primary" data-a="scan" title="Re-read the OPML now" aria-label="Re-read the OPML now">${ICON.scan}</button>
<button class="btn ico" data-a="read" title="Mark all read" aria-label="Mark all read">${ICON.checks}</button>
<button class="btn ico" data-a="pin" title="${f.pinned?'Unpin from the top of the feed list':'Pin to the top of the feed list'}" aria-label="${f.pinned?'Unpin':'Pin'}" aria-pressed="${!!f.pinned}">${f.pinned?ICON.pinOn:ICON.pin}</button>
<button class="btn ico" data-a="settings" title="Settings" aria-label="Settings">${ICON.settings}</button>
<button class="btn ico danger" data-a="rm" title="Unsubscribe" aria-label="Unsubscribe">${ICON.circleMinus}</button>
</div>
@@ -157,6 +159,12 @@ async function feedAction(a,f){
if(a==='scan'){ toast('Scanning '+(f.title||f.id)+'…'); await api('/api/fetch',{method:'POST',body:JSON.stringify({feed:f.id,force:true})}); }
if(a==='read'){ const r=await api(`/api/feeds/${encodeURIComponent(f.id)}/read-all`,{method:'POST'}); toast(`Marked ${r.marked} read`); await loadFeeds(true); renderFeed(); loadEntries(); }
if(a==='rm') removeFeed(f);
if(a==='pin'){
try{
await api(`/api/feeds/${encodeURIComponent(f.id)}`,{method:'PATCH',body:JSON.stringify({pinned:!f.pinned})});
await loadFeeds(true); renderFeed();
}catch(e){ toast(e.message,true); }
}
if(a==='settings') settingsModal(f);
if(a==='dl') downloadLatestModal(f);
}

View File

@@ -53,19 +53,25 @@ function renderFeeds(){
const shown=S.feeds.filter(f=>!q||(f.title||f.id).toLowerCase().includes(q));
if(!shown.length){ list.insertAdjacentHTML('beforeend','<p class="empty" style="padding:20px 8px">No feeds.</p>'); return done(); }
// Feeds from a subscribed OPML sit under it, so the group reads as one thing.
// Feeds from a subscribed OPML sit under it, so the group reads as one thing. Pinned feeds
// go first: a folder with its feeds under it, a feed from inside one lifted out of it.
const byId=Object.fromEntries(shown.map(f=>[f.id,f]));
const inside=f=>shown.filter(c=>c.group===f.id&&!c.pinned);
const tops=shown.filter(f=>f.pinned||!(f.group&&byId[f.group]));
const order=[];
for(const f of shown){
if(f.group && byId[f.group]) continue; // drawn under its parent instead
for(const f of [...tops.filter(f=>f.pinned),...tops.filter(f=>!f.pinned)]){
order.push([f,0]);
// A subscription can hold dozens of feeds, so a folder starts closed. Searching
// opens them all, or matches inside a closed folder would be invisible.
if(expanded.has(f.id) || q)
for(const c of shown.filter(c=>c.group===f.id).sort(unreadFirst)) order.push([c,1]);
for(const c of inside(f).sort(unreadFirst)) order.push([c,1]);
}
// The rule under the pinned block goes under its last row: a pinned folder's last feed when
// it is open.
const lastTop=order.findIndex(([f,d])=>!d&&!f.pinned);
const lastPin=(lastTop<0?order.filter(([f])=>f.pinned):order.slice(0,lastTop)).pop()?.[0];
for(const [f,depth] of order){
const kids=shown.filter(c=>c.group===f.id).length;
const kids=inside(f).length;
// A subscription holds no entries itself, so its counts are the sum of what is inside --
// taken from every feed it holds, not just the ones a filter left showing.
const mine=S.feeds.filter(c=>c.group===f.id);
@@ -79,7 +85,8 @@ function renderFeeds(){
const bad=c=>c.failing?.reason||c.last_error;
const err=mine.length ? mine.map(bad).find(Boolean) : bad(f);
const el=document.createElement('div');
el.className='feed'+(S.feed===f.id?' sel':'')+(depth?' child':'')+(kids?' group':'');
el.className='feed'+(S.feed===f.id?' sel':'')+(depth?' child':'')+(kids?' group':'')+
(f.pinned&&!depth?' pinned':'')+(f===lastPin&&lastTop>=0?' lastpin':'');
el.tabIndex=0; el.dataset.id=f.id;
const open = !!(kids && (expanded.has(f.id) || q));
el.innerHTML =
@@ -88,7 +95,7 @@ function renderFeeds(){
(kids?`<button class="chev${err?' bad':''}" aria-expanded="${open}" title="${err?`A feed inside has a problem: ${esc(err)}`:'Show or hide the feeds inside'}" aria-label="Show or hide the feeds inside">${ICON.caret}</button>`
:err?`<span class="ferr" role="img" title="${esc(err)}" aria-label="Error: ${esc(err)}">${ICON.alert}</span>`:'')+
(mine.length?folderArt(f,mine):artHTML(f.image,f.title||f.id))+
`<div class="txt"><b>${esc(f.title||f.id)}</b><small>`+
`<div class="txt"><b>${f.pinned?`<span class="fpin" title="Pinned">${ICON.pinOn}</span>`:''}${esc(f.title||f.id)}</b><small>`+
`${mine.length?plural(mine.length,'feed'):plural(eps,'item')} · ${saved} downloaded`+
`</small></div>`+
(f.orphaned?'<span class="tag" title="No longer listed, kept because it has downloads">Gone</span>':'')+