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

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