diff --git a/crates/ipx/CHANGELOG.md b/crates/ipx/CHANGELOG.md index 6d856fa..7876894 100644 --- a/crates/ipx/CHANGELOG.md +++ b/crates/ipx/CHANGELOG.md @@ -14,7 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Signals: a feed of your own, picked out of the noise of everything you subscribe to, holding the items TypeSafe's Jev says are about the words you give it, from the last 30 days and every new one, judged on your own TypeSafe key (`/api/signals`, and `typesafe_key` on `/api/me`) (#254). +- Signals: a feed of your own, picked out of the noise of everything you subscribe to, holding the items TypeSafe's Jev says are about the subject you give it: a name, words, and a sentence of your own if they are not enough, from the last 30 days and every new one, judged on your own TypeSafe key (`/api/signals`, and `typesafe_key` on `/api/me`) (#254). ### Changed diff --git a/crates/ipx/src/db.rs b/crates/ipx/src/db.rs index 70a1196..46b637b 100644 --- a/crates/ipx/src/db.rs +++ b/crates/ipx/src/db.rs @@ -106,6 +106,13 @@ async fn create_missing(orm: &sea_orm::DatabaseConnection) -> Result<()> { let sql = "ALTER TABLE users ADD COLUMN typesafe_key TEXT"; orm.execute_unprepared(sql).await.with_context(|| sql.to_owned())?; } + if orm.execute_unprepared("SELECT description FROM signals LIMIT 0").await.is_err() { + // Every verdict so far answered the old question, any one keyword on its own, which put + // a game's remaster in AI Development (#259): all of them are asked again. + for sql in ["ALTER TABLE signals ADD COLUMN description TEXT", "DELETE FROM signal_verdicts"] { + orm.execute_unprepared(sql).await.with_context(|| sql.to_owned())?; + } + } if orm.execute_unprepared("SELECT pause_ms FROM deadair_prefs LIMIT 0").await.is_err() { let sql = "ALTER TABLE deadair_prefs ADD COLUMN pause_ms BIGINT"; orm.execute_unprepared(sql).await.with_context(|| sql.to_owned())?; @@ -2539,9 +2546,19 @@ pub struct SignalRow { pub id: i64, pub name: String, pub keywords: Vec, + pub description: Option, pub unread: i64, } +/// One Signal's question about an item: what it is called, its words, and its owner's sentence. +#[derive(Debug, Clone)] +pub struct Ask { + pub id: i64, + pub name: String, + pub keywords: Vec, + pub description: Option, +} + /// An item still to be put to Jev for someone, with each of their Signals that has not been /// asked about it. #[derive(Debug)] @@ -2553,7 +2570,7 @@ pub struct Unjudged { pub feed_title: Option, pub title: Option, pub description: Option, - pub signals: Vec<(i64, Vec)>, + pub signals: Vec, } /// How far back a Signal looks when it is made: a month of items, not every back catalogue. @@ -2574,7 +2591,7 @@ impl Db { let user = a.p(user_id); let one = id.map(|i| format!("AND g.id = {}", a.p(i))).unwrap_or_default(); let sql = format!( - "SELECT g.id, g.name, g.keywords, + "SELECT g.id, g.name, g.keywords, g.description, (SELECT count(*) FROM signal_verdicts v JOIN entries e ON e.feed_id = v.feed_id AND e.guid = v.guid JOIN subscriptions s ON s.user_id = g.user_id AND s.feed_id = v.feed_id @@ -2591,17 +2608,19 @@ impl Db { id: r.try_get("", "id")?, name: r.try_get("", "name")?, keywords: keywords(r.try_get("", "keywords")?).unwrap_or_default(), + description: r.try_get("", "description")?, unread: r.try_get("", "unread")?, }) }) .collect() } - pub async fn add_signal(&self, user_id: i64, name: &str, words: &[String]) -> Result { + pub async fn add_signal(&self, user_id: i64, name: &str, words: &[String], description: Option<&str>) -> Result { let m = signals::ActiveModel { user_id: Set(user_id), name: Set(name.to_owned()), keywords: Set(serde_json::to_string(words)?), + description: Set(description.map(str::to_owned)), created: Set(now()), ..Default::default() } @@ -2610,9 +2629,17 @@ impl Db { Ok(m.id) } - /// Renames a Signal or gives it new keywords. New keywords put every item to Jev again, since - /// what it said was about the old ones. False for a Signal that is not this person's. - pub async fn set_signal(&self, user_id: i64, id: i64, name: Option<&str>, words: Option<&[String]>) -> Result { + /// Renames a Signal, or gives it new words or a new sentence. Either of those puts every item + /// to Jev again, since what it said was about the old ones; a new name does too, since the + /// question is about the name (#259). False for a Signal that is not this person's. + pub async fn set_signal( + &self, + user_id: i64, + id: i64, + name: Option<&str>, + words: Option<&[String]>, + description: Option>, + ) -> Result { if self.signal(user_id, id).await?.is_none() { return Ok(false); } @@ -2622,9 +2649,12 @@ impl Db { } if let Some(w) = words { m.keywords = Set(serde_json::to_string(w)?); - signal_verdicts::Entity::delete_many().filter(signal_verdicts::Column::SignalId.eq(id)).exec(&self.orm).await?; } - if name.is_some() || words.is_some() { + if let Some(d) = description { + m.description = Set(d.map(str::to_owned)); + } + if name.is_some() || words.is_some() || description.is_some() { + signal_verdicts::Entity::delete_many().filter(signal_verdicts::Column::SignalId.eq(id)).exec(&self.orm).await?; m.update(&self.orm).await?; } Ok(true) @@ -2662,7 +2692,7 @@ impl Db { }; let limit = a.p(limit); let sql = format!( - "SELECT g.id AS signal_id, g.keywords, g.user_id, u.typesafe_key, e.feed_id, e.guid, e.title, + "SELECT g.id AS signal_id, g.name, g.keywords, g.description AS signal_description, g.user_id, u.typesafe_key, e.feed_id, e.guid, e.title, e.description, f.title AS feed_title FROM signals g JOIN users u ON u.id = g.user_id @@ -2682,7 +2712,13 @@ impl Db { for r in self.rows(&sql, a.0).await? { let (user_id, feed_id, guid): (i64, String, String) = (r.try_get("", "user_id")?, r.try_get("", "feed_id")?, r.try_get("", "guid")?); - let signal = (r.try_get("", "signal_id")?, keywords(r.try_get("", "keywords")?).unwrap_or_default()); + let signal = Ask { + id: r.try_get("", "signal_id")?, + name: r.try_get("", "name")?, + keywords: keywords(r.try_get("", "keywords")?).unwrap_or_default(), + // Named apart from the item's own description, which is in the same row. + description: r.try_get("", "signal_description")?, + }; // Rows of one item for one person come together, by the ORDER BY. match out.last_mut() { Some(u) if (u.user_id, &u.feed_id, &u.guid) == (user_id, &feed_id, &guid) => u.signals.push(signal), @@ -3331,9 +3367,9 @@ mod tests { .await .unwrap(); let words = |w: &[&str]| w.iter().map(|s| s.to_string()).collect::>(); - let f1 = db.add_signal(1, "F1", &words(&["formula 1"])).await.unwrap(); - let food = db.add_signal(1, "Food", &words(&["baking"])).await.unwrap(); - db.add_signal(2, "Theirs", &words(&["x"])).await.unwrap(); + let f1 = db.add_signal(1, "F1", &words(&["formula 1"]), None).await.unwrap(); + let food = db.add_signal(1, "Food", &words(&["baking"]), Some("Bread and cakes")).await.unwrap(); + db.add_signal(2, "Theirs", &words(&["x"]), None).await.unwrap(); assert!(db.unjudged(&[], 50).await.unwrap().is_empty(), "nobody has a key yet"); db.set_typesafe_key(1, Some("k")).await.unwrap(); @@ -3341,6 +3377,8 @@ mod tests { let mut seen: Vec<_> = queue.iter().map(|u| (u.guid.as_str(), u.signals.len())).collect(); seen.sort(); assert_eq!(seen, [("x", 2), ("y", 2)], "each recent subscribed item once, with both Signals; old and unsubscribed left out"); + let ask = queue[0].signals.iter().find(|a| a.id == food).unwrap(); + assert_eq!((ask.name.as_str(), ask.description.as_deref()), ("Food", Some("Bread and cakes")), "Jev is asked about the name and the sentence"); assert!(db.unjudged(&[1], 50).await.unwrap().is_empty(), "a refused key is passed over"); db.record_verdicts("f", "x", &[(f1, true), (food, false)]).await.unwrap(); @@ -3358,11 +3396,15 @@ mod tests { assert_eq!(db.signal(1, f1).await.unwrap().unwrap().unread, 0, "read in its feed is read in the Signal"); assert!(db.signal(2, f1).await.unwrap().is_none(), "someone else's Signal is not found"); - assert!(!db.set_signal(2, f1, Some("mine"), None).await.unwrap()); + assert!(!db.set_signal(2, f1, Some("mine"), None, None).await.unwrap()); assert!(!db.remove_signal(2, f1).await.unwrap()); - db.set_signal(1, f1, None, Some(&words(&["f1"]))).await.unwrap(); + db.set_signal(1, f1, None, Some(&words(&["f1"])), None).await.unwrap(); assert_eq!(db.signal_count(1, f1, Filter::All, None).await.unwrap(), 0, "new keywords are judged afresh"); + db.record_verdicts("f", "x", &[(f1, true)]).await.unwrap(); + db.set_signal(1, f1, None, None, Some(Some("Grand prix racing"))).await.unwrap(); + assert_eq!(db.signal_count(1, f1, Filter::All, None).await.unwrap(), 0, "so is a new sentence"); + assert_eq!(db.signal(1, f1).await.unwrap().unwrap().description.as_deref(), Some("Grand prix racing")); assert!(db.remove_signal(1, food).await.unwrap()); db.record_verdicts("f", "x", &[(food, true)]).await.unwrap(); assert_eq!(db.signals(1).await.unwrap().len(), 1, "a verdict for a removed Signal is not kept"); diff --git a/crates/ipx/src/entity.rs b/crates/ipx/src/entity.rs index bb78cd5..37e19f9 100644 --- a/crates/ipx/src/entity.rs +++ b/crates/ipx/src/entity.rs @@ -304,6 +304,10 @@ pub mod signals { /// JSON array of strings. #[sea_orm(column_type = "Text")] pub keywords: String, + /// What its owner means by it, in a sentence of their own, when the name and the words + /// are not enough (#259). + #[sea_orm(column_type = "Text", nullable)] + pub description: Option, pub created: i64, } diff --git a/crates/ipx/src/signals.rs b/crates/ipx/src/signals.rs index 6af62ed..47bab2f 100644 --- a/crates/ipx/src/signals.rs +++ b/crates/ipx/src/signals.rs @@ -10,7 +10,7 @@ use anyhow::Result; use tracing::Instrument; use crate::Ctx; -use crate::db::Unjudged; +use crate::db::{Ask, Unjudged}; use crate::feed; /// Items put to Jev a pass. A pass ends early once there are none left, so this only says how @@ -146,7 +146,7 @@ async fn judge(ctx: &Ctx, item: &Unjudged) -> Result> { .instrument(span) .await?; // A question Jev left unanswered stays unjudged and is asked again. - Ok(item.signals.iter().filter_map(|(id, _)| hit(&answer, *id).map(|h| (*id, h))).collect()) + Ok(item.signals.iter().filter_map(|a| hit(&answer, a.id).map(|h| (a.id, h))).collect()) } /// The item as Jev sees it, as text, which is what its docs show: an object with an empty feed @@ -159,7 +159,7 @@ fn request(item: &Unjudged) -> serde_json::Value { item.title.as_deref().unwrap_or("") ); let questions: serde_json::Map = - item.signals.iter().map(|(id, words)| (key(*id), question(words))).collect(); + item.signals.iter().map(|a| (key(a.id), question(a))).collect(); serde_json::json!({ "model": "jev-latest", "state": state, "questions": questions }) } @@ -167,15 +167,22 @@ fn key(id: i64) -> String { format!("signal_{id}") } -/// `criteria` is an object of what true and false mean: a sentence on its own was refused with a -/// 422, every call, and no Signal ever filled (#257). -fn question(words: &[String]) -> serde_json::Value { +/// One subject, named by the Signal and described by its words and its owner's sentence. Asked +/// whether an item was about any one of its words, AI Development (ai, development, programming) +/// took a game's remaster at 0.90 and a C++ tutorial at 0.99; asked about the subject, 0.06 and +/// 0.02, with AI code review still 0.94 (#259). `criteria` is an object of what true and false +/// mean: a sentence on its own was refused with a 422 (#257). +fn question(a: &Ask) -> serde_json::Value { + let mut said = format!("The reader follows a subject they call \"{}\", described by these words: {}.", a.name, a.keywords.join(", ")); + if let Some(d) = &a.description { + said.push_str(&format!(" In their own words: {d}")); + } serde_json::json!({ "type": "noul", - "instructions": format!("Is this item about any of these: {}?", words.join(", ")), + "instructions": format!("{said} Is this item about that subject?"), "criteria": { - "true": "The item's subject is one of them, or plainly part of one.", - "false": "None of them is its subject: one is only mentioned in passing, or none is.", + "true": "The item's main subject is that subject, as the name, the words and the reader's description together describe it.", + "false": "It is about something else, or touches the subject only in passing.", }, }) } @@ -224,14 +231,19 @@ mod tests { feed_title: Some("Pit Lane".into()), title: Some("Monaco".into()), description: Some("

Qualifying report

".into()), - signals: vec![(3, vec!["formula 1".into(), "f1".into()]), (4, vec!["cooking".into()])], + signals: vec![ + Ask { id: 3, name: "F1".into(), keywords: vec!["formula 1".into(), "f1".into()], description: None }, + Ask { id: 4, name: "Food".into(), keywords: vec!["cooking".into()], description: Some("Recipes, not restaurants.".into()) }, + ], }; let r = request(&item); assert_eq!(r["state"], "Feed: Pit Lane\nTitle: Monaco\n\nQualifying report"); let q = &r["questions"]["signal_3"]; assert_eq!(q["type"], "noul"); - assert!(q["instructions"].as_str().unwrap().contains("formula 1, f1")); + let says = q["instructions"].as_str().unwrap(); + assert!(says.contains("\"F1\"") && says.contains("formula 1, f1"), "one subject, by its name and its words: {says}"); + assert!(!says.contains("own words"), "no sentence, none said"); assert!(q["criteria"]["true"].is_string() && q["criteria"]["false"].is_string(), "TypeSafe takes an object, not a sentence"); - assert!(r["questions"]["signal_4"]["instructions"].as_str().unwrap().contains("cooking")); + assert!(r["questions"]["signal_4"]["instructions"].as_str().unwrap().contains("In their own words: Recipes, not restaurants.")); } } diff --git a/crates/ipx/src/web.rs b/crates/ipx/src/web.rs index c5ea191..321a091 100644 --- a/crates/ipx/src/web.rs +++ b/crates/ipx/src/web.rs @@ -426,6 +426,17 @@ async fn patch_me( struct SignalBody { name: Option, keywords: Option>, + /// What its owner means by it, in their words: a string sets it, null or empty clears it. + #[serde(default, deserialize_with = "double_option")] + description: Option>, +} + +fn signal_description(d: Option) -> Result, ApiError> { + let d = d.map(|d| d.trim().to_owned()).filter(|d| !d.is_empty()); + if d.as_ref().is_some_and(|d| d.chars().count() > 500) { + return Err(ApiError::bad_request("a Signal's description is up to 500 characters")); + } + Ok(d) } fn signal_name(name: &str) -> Result { @@ -459,7 +470,8 @@ async fn add_signal( ) -> Result, ApiError> { let name = signal_name(body.name.as_deref().unwrap_or(""))?; let words = signal_words(body.keywords.unwrap_or_default())?; - let id = state.ctx.db.add_signal(user.id, &name, &words).await?; + let description = signal_description(body.description.flatten())?; + let id = state.ctx.db.add_signal(user.id, &name, &words, description.as_deref()).await?; state.ctx.signals.notify_one(); Ok(Json(serde_json::json!({ "id": id }))) } @@ -472,10 +484,12 @@ async fn patch_signal( ) -> Result { let name = body.name.as_deref().map(signal_name).transpose()?; let words = body.keywords.map(signal_words).transpose()?; - if !state.ctx.db.set_signal(user.id, id, name.as_deref(), words.as_deref()).await? { + let description = body.description.map(signal_description).transpose()?; + let changed = name.is_some() || words.is_some() || description.is_some(); + if !state.ctx.db.set_signal(user.id, id, name.as_deref(), words.as_deref(), description.as_ref().map(|d| d.as_deref())).await? { return Err(ApiError::not_found("no such Signal")); } - if words.is_some() { + if changed { state.ctx.signals.notify_one(); } Ok(StatusCode::NO_CONTENT) diff --git a/docs/signals.md b/docs/signals.md index e9a6dbd..caad596 100644 --- a/docs/signals.md +++ b/docs/signals.md @@ -1,8 +1,9 @@ # Signals -A Signal is a feed someone makes from their own subscriptions (#254). They give it a name and the -words to listen for (`formula 1`, `f1`). TypeSafe's Jev reads each item of the feeds they subscribe -to and picks out the ones about those words. Those items show in the Signal as well as in their +A Signal is a feed someone makes from their own subscriptions (#254). They give it a name, the +words to listen for (`formula 1`, `f1`) and, when those are not enough, a sentence saying what they +mean (#259). TypeSafe's Jev reads each item of the feeds they subscribe to and picks out the ones +about that subject: the name, the words and the sentence together, not any one word. Those items show in the Signal as well as in their own feed. The rest stays noise, where it was. Each person brings their own TypeSafe key, entered in Settings. Nothing is judged for someone @@ -17,8 +18,9 @@ their Signals that has not heard about the item yet. words under its name and its unread count. - **The folder's page** lists the Signals, as an OPML's page lists its feeds, with a + of its own. - **A Signal's page** is an ordinary item list: All, Unread, Downloaded and Pinned, search and sort. - Its header says what it listens for, and that nothing is picked out without a key. Edit changes - the name or the words; Delete removes the Signal and leaves the items in their feeds. + Its header says what it listens for (the sentence, or the words), and that nothing is picked + out without a key. Edit changes the name, the words or the sentence; Delete removes the Signal + and leaves the items in their feeds. - **Read state is the item's.** Reading an item in a Signal reads it in its feed, and the other way round. The counts follow without a reload (#258). - **Settings, TypeSafe key.** A password field. A key saves on leaving the field, and the bin @@ -73,7 +75,9 @@ judge sleeps until woken or for 5 minutes. `signal_verdicts` records each answer, fitting or not. An item with no row has not been asked yet; an item with a row is never asked again. So the cost is one call per item per person, once. -Changing a Signal's words deletes its verdicts, and everything in the window is asked again. +Changing a Signal's name, words or sentence deletes its verdicts, and everything in the window is +asked again. The first start with sentences (#259) deleted every verdict, since they answered the +old question. ## TypeSafe @@ -93,18 +97,18 @@ Sent with `Authorization: Bearer `, for an item and two of the "questions": { "signal_3": { "type": "noul", - "instructions": "Is this item about any of these: formula 1, f1?", + "instructions": "The reader follows a subject they call \"F1\", described by these words: formula 1, f1. Is this item about that subject?", "criteria": { - "true": "The item's subject is one of them, or plainly part of one.", - "false": "None of them is its subject: one is only mentioned in passing, or none is." + "true": "The item's main subject is that subject, as the name, the words and the reader's description together describe it.", + "false": "It is about something else, or touches the subject only in passing." } }, "signal_4": { "type": "noul", - "instructions": "Is this item about any of these: baking?", + "instructions": "The reader follows a subject they call \"Food\", described by these words: baking. In their own words: Bread and cakes, not restaurants. Is this item about that subject?", "criteria": { - "true": "The item's subject is one of them, or plainly part of one.", - "false": "None of them is its subject: one is only mentioned in passing, or none is." + "true": "The item's main subject is that subject, as the name, the words and the reader's description together describe it.", + "false": "It is about something else, or touches the subject only in passing." } } } @@ -116,10 +120,16 @@ Sent with `Authorization: Bearer `, for an item and two of the - Each question is keyed `signal_`, and its answer comes back under the same key. - `criteria` is an object saying what true and false mean. A sentence there is refused with a 422 (#257). +- **One subject, not any word.** The first question was "Is this item about any of these: ai, + development, programming?", so one broad word was enough: AI Development took Sega pulling a game + remaster (0.90), a Surface Laptop review (0.95) and a C++ tutorial (0.99), 54 items in all. Asked + about the subject, with the sentence "AI tools for writing software: coding agents, AI dev tools, + vibe coding, AI in code review.", those scored 0.06, 0.16 and 0.02, and 5 of the 54 stayed in, + AI code review in the Linux kernel highest at 0.94 (#259). ### Its answer -What TypeSafe answered to the request above, on 2026-10-16: +What TypeSafe answered to a request of this shape, on 2026-10-16: ```json { @@ -158,8 +168,9 @@ does not exist. ```json [ - { "id": 1, "name": "AI Development", "keywords": ["ai", "development", "programming"], "unread": 5 }, - { "id": 2, "name": "Apple", "keywords": ["apple", "mac", "iphone"], "unread": 0 } + { "id": 1, "name": "AI Development", "keywords": ["ai", "development", "programming"], + "description": "AI tools for writing software: coding agents, AI dev tools, vibe coding, AI in code review.", "unread": 5 }, + { "id": 2, "name": "Apple", "keywords": ["apple", "mac", "iphone"], "description": null, "unread": 0 } ] ``` @@ -169,17 +180,18 @@ not read. ### `POST /api/signals` ```json -{ "name": "Formula 1", "keywords": ["formula 1", "f1"] } +{ "name": "Formula 1", "keywords": ["formula 1", "f1"], "description": "Grand prix racing, not road cars." } ``` Answers `{ "id": 3 }` and wakes the judge. A name is 1 to 100 characters. Between 1 and 50 words -are allowed, each up to 200 characters, kept once whatever their case. Anything else is a `400` -saying so. +are allowed, each up to 200 characters, kept once whatever their case. `description` is optional, +up to 500 characters. Anything else is a `400` saying so. ### `PATCH /api/signals/{id}` -Either field or both: `{ "name": "F1" }`, `{ "keywords": ["f1", "grand prix"] }`. New keywords drop -the Signal's verdicts and wake the judge. Answers `204`, or `404` for a Signal that is not yours. +Any of the three: `{ "name": "F1" }`, `{ "keywords": ["f1", "grand prix"] }`, +`{ "description": null }` (null or empty clears it). Any change drops the Signal's verdicts and +wakes the judge, since each is part of the question. Answers `204`, or `404` for a Signal that is not yours. ### `DELETE /api/signals/{id}` @@ -248,6 +260,7 @@ erDiagram bigint user_id text name text keywords "JSON array" + text description "NULL: none" bigint created } signal_verdicts { @@ -259,8 +272,8 @@ erDiagram ``` - `signals` and `signal_verdicts` are created from their entities like every table - (`crates/ipx/src/entity.rs`). `users.typesafe_key` was added to an existing table, so - `create_missing` adds the column when it is missing. + (`crates/ipx/src/entity.rs`). `users.typesafe_key` and `signals.description` were added to + existing tables, so `create_missing` adds each column when it is missing. - A Signal's list is `entries_where` with `EXISTS (SELECT 1 FROM signal_verdicts v WHERE v.signal_id = $n AND v.feed_id = e.feed_id AND v.guid = e.guid AND v.hit)` on top of the subscribed-feeds scope. So filters, search, sort, block lists and read state all diff --git a/tests/ui/fixtures/serve.js b/tests/ui/fixtures/serve.js index 465b7cc..327f165 100644 --- a/tests/ui/fixtures/serve.js +++ b/tests/ui/fixtures/serve.js @@ -21,7 +21,7 @@ http.createServer((req, res) => { const title = String(state).toLowerCase(); const answers = {}; for (const [id, q] of Object.entries(questions)) { - const words = q.instructions.replace(/^.*: /, '').replace(/\?$/, '').split(', '); + const words = /described by these words: ([^.]*)\./.exec(q.instructions)[1].split(', '); answers[id] = { noul: words.some(w => title.includes(w.toLowerCase())) ? 0.9 : 0.1 }; } res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ answers })); diff --git a/web/CHANGELOG.md b/web/CHANGELOG.md index 407f150..ce0d99e 100644 --- a/web/CHANGELOG.md +++ b/web/CHANGELOG.md @@ -13,7 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Signals pick what matters out of the noise of every feed you subscribe to: New signal takes a name and what to listen for, and its page lists the items from your feeds that are about it, read and unread as in their own feeds. Settings takes your TypeSafe key, which Signals need (#254). They are kept in a Signals folder first in the feed list, under All Subscriptions, closed or open as an OPML is, with a + on its row for a new one and its own page listing them (#255, #256). +- Signals pick what matters out of the noise of every feed you subscribe to: New signal takes a name, the words to listen for and, if those are not enough, a sentence saying what you mean, and its page lists the items from your feeds that are about it, read and unread as in their own feeds. Settings takes your TypeSafe key, which Signals need (#254). They are kept in a Signals folder first in the feed list, under All Subscriptions, closed or open as an OPML is, with a + on its row for a new one and its own page listing them (#255, #256). - A list opens at once when you have seen it before or the page fetched it ahead: All Subscriptions and the feeds with something unread are fetched when the page loads, and a feed or filter as the pointer or a finger reaches it, then refreshed behind once shown. A list that has to be waited for shows a spinner instead of nothing (#247). Currently Listening and the Directory's front page are fetched ahead the same way and open at once (#248). ### Changed diff --git a/web/src/signals.ts b/web/src/signals.ts index 3ae6dfe..58bc31d 100644 --- a/web/src/signals.ts +++ b/web/src/signals.ts @@ -98,7 +98,7 @@ function signalHead(sg: Signal){
${ICON.signal}

${esc(sg.name)}

-
Picked out of the noise of your ${plural(S.feeds.length,'feed')}: anything about ${esc(sg.keywords.join(', '))} · ${sg.unread} unread
+
Picked out of the noise of your ${plural(S.feeds.length,'feed')}: ${esc(sg.description||sg.keywords.join(', '))} · ${sg.unread} unread
${nokey?'
No TypeSafe key yet, so nothing is being picked out. Add one in Settings.
':''}
@@ -126,10 +126,13 @@ function signalModal(sg?: Signal){
-
${chipsHTML(sg?.keywords||[],'sgkwin')}
+
${chipsHTML(sg?.keywords||[],'sgkwin')}
+
+ TypeSafe's Jev reads every item in the feeds you subscribe to, the last 30 - days of them and each new one, and pulls out the ones about any of these. The rest stays - noise, in its own feed. Each item is one call on your TypeSafe key.${sg?' Changing these reads everything again.':''}
+ days of them and each new one, and pulls out the ones about this subject: its name, these + words and your sentence together. The rest stays noise, in its own feed. Each item is one + call on your TypeSafe key.${sg?' A change here reads everything again.':''}
${nokey?'

Add your TypeSafe key in Settings first: without one, nothing is picked out.

':''}
`); @@ -139,7 +142,7 @@ function signalModal(sg?: Signal){ const name=$('#sgname').value.trim(), keywords=chipWords($('#sgkw')); if(!name){ $('#sgname').focus(); return; } if(!keywords.length){ $('#sgkwin').focus(); return; } - const body=JSON.stringify({name,keywords}); + const body=JSON.stringify({name,keywords,description:$('#sgdesc').value.trim()||null}); try{ const r=sg ? await api(`/api/signals/${sg.id}`,{method:'PATCH',body}) : await api('/api/signals',{method:'POST',body}); diff --git a/web/src/types.ts b/web/src/types.ts index d6c75c3..e45be10 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -91,6 +91,8 @@ interface Signal { id: number; name: string; keywords: string[]; + /// What its owner means by it, in their words (#259). + description: string | null; unread: number; }