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

@@ -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() {