A Signal's own icon, from all of Font Awesome Free, searchable (#261)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-16 18:36:08 +00:00
parent 9e5dcbb8c4
commit b18c92d1f5
15 changed files with 269 additions and 31 deletions

View File

@@ -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 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).
- 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, each with an icon of its own if you like (`/api/signals`, and `typesafe_key` on `/api/me`) (#254).
### Changed

View File

@@ -113,6 +113,10 @@ async fn create_missing(orm: &sea_orm::DatabaseConnection) -> Result<()> {
orm.execute_unprepared(sql).await.with_context(|| sql.to_owned())?;
}
}
if orm.execute_unprepared("SELECT icon FROM signals LIMIT 0").await.is_err() {
let sql = "ALTER TABLE signals ADD COLUMN icon TEXT";
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())?;
@@ -2547,9 +2551,19 @@ pub struct SignalRow {
pub name: String,
pub keywords: Vec<String>,
pub description: Option<String>,
pub icon: Option<String>,
pub unread: i64,
}
/// What a Signal is changed to: each field None to leave it as it is.
#[derive(Default)]
pub struct SignalChange<'a> {
pub name: Option<&'a str>,
pub words: Option<&'a [String]>,
pub description: Option<Option<&'a str>>,
pub icon: Option<Option<&'a str>>,
}
/// One Signal's question about an item: what it is called, its words, and its owner's sentence.
#[derive(Debug, Clone)]
pub struct Ask {
@@ -2591,7 +2605,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, g.description,
"SELECT g.id, g.name, g.keywords, g.description, g.icon,
(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
@@ -2609,6 +2623,7 @@ impl Db {
name: r.try_get("", "name")?,
keywords: keywords(r.try_get("", "keywords")?).unwrap_or_default(),
description: r.try_get("", "description")?,
icon: r.try_get("", "icon")?,
unread: r.try_get("", "unread")?,
})
})
@@ -2629,17 +2644,11 @@ impl Db {
Ok(m.id)
}
/// 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<Option<&str>>,
) -> Result<bool> {
/// Changes a Signal. A new name, words or sentence puts every item to Jev again, since what it
/// said was about the old question (#259); a new icon does not. False for a Signal that is not
/// this person's.
pub async fn set_signal(&self, user_id: i64, id: i64, change: SignalChange<'_>) -> Result<bool> {
let SignalChange { name, words, description, icon } = change;
if self.signal(user_id, id).await?.is_none() {
return Ok(false);
}
@@ -2653,8 +2662,13 @@ impl Db {
if let Some(d) = description {
m.description = Set(d.map(str::to_owned));
}
if let Some(i) = icon {
m.icon = Set(i.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?;
}
if name.is_some() || words.is_some() || description.is_some() || icon.is_some() {
m.update(&self.orm).await?;
}
Ok(true)
@@ -3396,15 +3410,19 @@ 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, None).await.unwrap());
assert!(!db.set_signal(2, f1, SignalChange { name: Some("mine"), ..Default::default() }).await.unwrap());
assert!(!db.remove_signal(2, f1).await.unwrap());
db.set_signal(1, f1, None, Some(&words(&["f1"])), None).await.unwrap();
db.set_signal(1, f1, SignalChange { words: Some(&words(&["f1"])), ..Default::default() }).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();
db.set_signal(1, f1, SignalChange { description: Some(Some("Grand prix racing")), ..Default::default() }).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"));
db.record_verdicts("f", "x", &[(f1, true)]).await.unwrap();
db.set_signal(1, f1, SignalChange { icon: Some(Some("solid/flag-checkered")), ..Default::default() }).await.unwrap();
assert_eq!(db.signal_count(1, f1, Filter::All, None).await.unwrap(), 1, "a new icon asks nothing again");
assert_eq!(db.signal(1, f1).await.unwrap().unwrap().icon.as_deref(), Some("solid/flag-checkered"));
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");

View File

@@ -308,6 +308,10 @@ pub mod signals {
/// are not enough (#259).
#[sea_orm(column_type = "Text", nullable)]
pub description: Option<String>,
/// Its icon, a Font Awesome Free one as `style/name` (`solid/robot`), in place of its
/// initials (#261).
#[sea_orm(column_type = "Text", nullable)]
pub icon: Option<String>,
pub created: i64,
}

View File

@@ -429,6 +429,24 @@ struct SignalBody {
/// 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<Option<String>>,
/// A Font Awesome Free icon, `solid/robot`: a string sets it, null or empty clears it.
#[serde(default, deserialize_with = "double_option")]
icon: Option<Option<String>>,
}
/// The page puts the name into an address (`/icons/<style>/<name>.svg`), so nothing but a style
/// and a plain name gets through.
fn signal_icon(i: Option<String>) -> Result<Option<String>, ApiError> {
let Some(i) = i.filter(|i| !i.is_empty()) else { return Ok(None) };
let ok = i.split_once('/').is_some_and(|(style, name)| {
["solid", "regular", "brands"].contains(&style)
&& (1..=64).contains(&name.len())
&& name.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
});
if !ok {
return Err(ApiError::bad_request("an icon is a Font Awesome Free one, such as solid/robot"));
}
Ok(Some(i))
}
fn signal_description(d: Option<String>) -> Result<Option<String>, ApiError> {
@@ -471,7 +489,12 @@ async fn add_signal(
let name = signal_name(body.name.as_deref().unwrap_or(""))?;
let words = signal_words(body.keywords.unwrap_or_default())?;
let description = signal_description(body.description.flatten())?;
let icon = signal_icon(body.icon.flatten())?;
let id = state.ctx.db.add_signal(user.id, &name, &words, description.as_deref()).await?;
if icon.is_some() {
let change = crate::db::SignalChange { icon: Some(icon.as_deref()), ..Default::default() };
state.ctx.db.set_signal(user.id, id, change).await?;
}
state.ctx.signals.notify_one();
Ok(Json(serde_json::json!({ "id": id })))
}
@@ -485,11 +508,18 @@ async fn patch_signal(
let name = body.name.as_deref().map(signal_name).transpose()?;
let words = body.keywords.map(signal_words).transpose()?;
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? {
let icon = body.icon.map(signal_icon).transpose()?;
let asks_again = name.is_some() || words.is_some() || description.is_some();
let change = crate::db::SignalChange {
name: name.as_deref(),
words: words.as_deref(),
description: description.as_ref().map(|d| d.as_deref()),
icon: icon.as_ref().map(|i| i.as_deref()),
};
if !state.ctx.db.set_signal(user.id, id, change).await? {
return Err(ApiError::not_found("no such Signal"));
}
if changed {
if asks_again {
state.ctx.signals.notify_one();
}
Ok(StatusCode::NO_CONTENT)
@@ -1211,6 +1241,17 @@ mod tests {
}
}
#[test]
fn a_signal_icon_is_a_style_and_a_plain_name() {
assert_eq!(signal_icon(Some("solid/robot".into())).ok().flatten().as_deref(), Some("solid/robot"));
assert_eq!(signal_icon(Some("brands/apple".into())).ok().flatten().as_deref(), Some("brands/apple"));
assert!(matches!(signal_icon(Some(String::new())), Ok(None)), "empty clears it");
// It becomes part of an address and a style attribute on the page.
for bad in ["solid/../../api/me", "light/robot", "solid/Robot", "solid/x');background:url(x", "robot", "solid/"] {
assert!(signal_icon(Some(bad.into())).is_err(), "{bad}");
}
}
#[test]
fn only_a_plain_theme_name_is_handed_on() {
assert!(theme_ok("dracula", "dark") && theme_ok("nordic", "auto"));

View File

@@ -16,6 +16,9 @@ their Signals that has not heard about the item yet.
#256). It is closed or open as an OPML's folder is, remembered in each browser. A + at the right
of the folder's row opens New signal. Inside the folder are the person's Signals, each with its
words under its name and its unread count.
- **An icon of its own** (#261), chosen in the Signal's dialog from all of Font Awesome Free 7.3.1,
the set the page's own icons come from: 2,883 icons, solid, regular and brands, searched by name
and Font Awesome's search terms ("ai" finds the robot). Without one, a Signal shows its initials.
- **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 (the sentence, or the words), and that nothing is picked
@@ -190,8 +193,10 @@ up to 500 characters. Anything else is a `400` saying so.
### `PATCH /api/signals/{id}`
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.
`{ "description": null }` (null or empty clears it), `{ "icon": "solid/robot" }`. A change to the
name, words or sentence drops the Signal's verdicts and wakes the judge, since each is part of the
question; a new icon does not. The page sends only what changed. An icon is `solid/`, `regular/` or
`brands/` and a plain name, since the page puts it into an address: anything else is a `400`. Answers `204`, or `404` for a Signal that is not yours.
### `DELETE /api/signals/{id}`
@@ -261,6 +266,7 @@ erDiagram
text name
text keywords "JSON array"
text description "NULL: none"
text icon "solid/robot, NULL: initials"
bigint created
}
signal_verdicts {
@@ -272,8 +278,12 @@ erDiagram
```
- `signals` and `signal_verdicts` are created from their entities like every table
(`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.
(`crates/ipx/src/entity.rs`). `users.typesafe_key`, `signals.description` and `signals.icon`
were added to existing tables, so `create_missing` adds each column when it is missing.
- The icons are files of ipx-web's, not the database's: `web/build.mjs` copies Font Awesome Free's
SVGs to `/icons/<style>/<name>.svg`, kept a day, and writes `/icons/index.json`, names, labels
and search terms, about 90 KB compressed, named by its hash and fetched when the picker first
opens. A Signal's tile draws its one file as a CSS mask, so it takes the theme's colour.
- 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

12
package-lock.json generated
View File

@@ -1,11 +1,14 @@
{
"name": "ipx-web",
"version": "1.0.3-dev",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ipx-web",
"version": "1.0.3-dev",
"dependencies": {
"@fortawesome/fontawesome-free": "7.3.1",
"@swc/core": "^1.16.2",
"@swc/html": "^1.16.2",
"hls.js": "1.6.19"
@@ -15,6 +18,15 @@
"typescript": "^7.0.2"
}
},
"node_modules/@fortawesome/fontawesome-free": {
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-7.3.1.tgz",
"integrity": "sha512-wmglKKPDIkgV3aWlZzWECCPoGIkYCulzBwxG9+w7rc5BGapZ6cPMpoPOT8k36J0Ni7PPX6c/rsoMWfS4d1MUMg==",
"license": "(CC-BY-4.0 AND OFL-1.1 AND MIT)",
"engines": {
"node": ">=6"
}
},
"node_modules/@playwright/test": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz",

View File

@@ -16,6 +16,7 @@
"typescript": "^7.0.2"
},
"dependencies": {
"@fortawesome/fontawesome-free": "7.3.1",
"@swc/core": "^1.16.2",
"@swc/html": "^1.16.2",
"hls.js": "1.6.19"

View File

@@ -154,3 +154,31 @@ test('a Signal is a place of its own, found by its id', () => {
assert.match(run(`listUrl(':signal:7')`), /^\/api\/signals\/7\/entries\?/);
run(`S.signals = []`);
});
test('the icon picker finds icons by the start of their words, the one named it first', () => {
const all = JSON.stringify([
['solid/caret-down', 'Caret Down', 'arrow'], ['solid/scarf', 'Scarf', 'clothing'], ['solid/taxi', 'Taxi', 'cab car'],
['solid/car-side', 'Car Side', 'auto vehicle'], ['solid/car', 'Car', 'auto vehicle'], ['solid/robot', 'Robot', 'ai bot machine'],
['brands/apple', 'Apple', 'fruit ios mac'],
]);
const ids = q => JSON.parse(run(`JSON.stringify(iconMatches(${all}, ${JSON.stringify(q)}, 10).map(i => i[0]))`));
// Named car, then car as a word of the name, then as a search term, then caret; never scarf.
assert.deepEqual(ids('car'), ['solid/car', 'solid/car-side', 'solid/taxi', 'solid/caret-down']);
assert.deepEqual(ids('ai'), ['solid/robot'], 'search terms count');
assert.deepEqual(ids('mac fruit'), ['brands/apple'], 'every word');
assert.equal(run(`iconMatches(${all}, '', 2).length`), 2, 'nothing typed is the first few');
});
test('editing a Signal sends only what changed, so a new icon asks Jev nothing again', () => {
const sg = `{id: 1, name: 'F1', keywords: ['f1'], description: null, icon: null, unread: 0}`;
assert.deepEqual(JSON.parse(run(`JSON.stringify(signalChanges(${sg}, {name: 'F1', keywords: ['f1'], description: null, icon: 'solid/car'}))`)),
{ icon: 'solid/car' });
assert.deepEqual(JSON.parse(run(`JSON.stringify(signalChanges(${sg}, {name: 'F1', keywords: ['f1', 'gp'], description: null, icon: null}))`)),
{ keywords: ['f1', 'gp'] });
});
test('a Signal draws its icon only when it is a style and a plain name', () => {
assert.match(run(`signalTile({name: 'F1', icon: 'solid/flag-checkered'})`), /url\('\/icons\/solid\/flag-checkered\.svg'\)/);
assert.doesNotMatch(run(`signalTile({name: 'F1', icon: "solid/x');background:url(evil"})`), /evil/);
assert.doesNotMatch(run(`signalTile({name: 'F1', icon: null})`), /ficon/);
});

View File

@@ -2084,9 +2084,15 @@ test('a Signal holds the items Jev says fit its keywords, read where they are re
await page.locator('#sgname').fill('F1');
await page.locator('#sgkwin').fill('monaco');
await page.keyboard.press('Enter');
// An icon of its own, found by searching Font Awesome Free (#261).
await page.locator('#sgicon').click();
await page.locator('#sgiconq').fill('flag checkered');
await page.locator('[data-ic="solid/flag-checkered"]').click();
await page.locator('#sgsave').click();
const row = page.locator('#feedlist .feed.child', { hasText: 'F1' });
await expect(row).toHaveClass(/sel/);
await expect(row.locator('.ficon')).toHaveAttribute('style', /\/icons\/solid\/flag-checkered\.svg/);
expect((await page.request.get('/icons/solid/flag-checkered.svg')).status()).toBe(200);
const signals = async () => (await (await page.request.get('/api/signals')).json());
const sg = (await signals()).find(s => s.name === 'F1');

View File

@@ -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, 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).
- 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). Each can have an icon of its own, searched for among all of Font Awesome Free, in place of its initials (#261).
- 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

View File

@@ -126,7 +126,7 @@ http://{$WEB_BIND::8080} {
# Scripts and the stylesheet are named by a hash of their contents (web/build.mjs), so a browser
# keeps them a year; the icons a day, under names that do not change.
@hashed path /app.js /admin.js /login.js /app.css /hls.js /boot.js
@hashed path /app.js /admin.js /login.js /app.css /hls.js /boot.js /icons/index.json
header @hashed Cache-Control "public, max-age=31536000, immutable"
# The same PNG as /favicon.png, for a browser that asks for /favicon.ico on its own.
header /favicon.ico Content-Type image/png

View File

@@ -592,6 +592,21 @@ input:focus,select:focus{outline:0;border-color:var(--accent)}
.feed.child .art{width:28px;height:28px;font-size:11px}
/* A Signal's tile is its icon, as a place's is: there is no artwork of its own. */
.art.sig .i{width:45%;height:45%}
/* A Signal's own icon (#261): Font Awesome's file as a mask, so it is drawn in the text's colour
and follows the theme as the page's own icons do. */
.ficon{
display:block;width:50%;height:50%;background:currentColor;
-webkit-mask:var(--icon) center/contain no-repeat;mask:var(--icon) center/contain no-repeat;
}
.iconpick{flex:none;border-radius:9px}
.iconpick .art{width:38px;height:38px;font-size:13px}
.icongrid{
display:grid;grid-template-columns:repeat(auto-fill,minmax(40px,1fr));gap:4px;
max-height:220px;overflow-y:auto;margin-top:8px;
}
.iconcell{height:40px;border-radius:8px;display:grid;place-items:center;color:var(--dim)}
.iconcell:hover,.iconcell[aria-selected="true"]{background:var(--raise);color:var(--fg)}
.iconcell .ficon{width:20px;height:20px}
/* New signal, at the end of the folder's row. */
.sigadd{width:26px;height:26px;margin-left:-4px}
.sigadd .i{width:12px;height:12px}

View File

@@ -38,6 +38,23 @@ const hash = s => crypto.createHash('sha256').update(s).digest('hex').slice(0, 1
const HLS = fs.readFileSync(path.join(here, '..', 'node_modules', 'hls.js', 'dist', 'hls.light.min.js'), 'utf8');
const HLS_URL = `/hls.js?v=${hash(HLS)}`;
// Font Awesome Free, the set the page's own icons come from, whole, for a Signal's icon (#261):
// each icon a file of its own, /icons/<style>/<name>.svg, drawn as a mask so it takes the theme's
// colour, and an index of names and search terms the picker fetches when it opens. The page's
// script is given the index's address, named by its hash, in place of __ICONS__.
const FA = path.join(here, '..', 'node_modules', '@fortawesome', 'fontawesome-free');
const FA_STYLES = ['solid', 'regular', 'brands'];
/// [`style/name`, label, search terms] for every icon, solid first.
export function iconIndex() {
const meta = JSON.parse(fs.readFileSync(path.join(FA, 'metadata', 'icon-families.json'), 'utf8'));
return FA_STYLES.flatMap(style => fs.readdirSync(path.join(FA, 'svgs', style)).sort().map(f => {
const name = f.slice(0, -4), m = meta[name] || {};
return [`${style}/${name}`, m.label || name, (m.search?.terms || []).join(' ')];
}));
}
const ICONS = JSON.stringify(iconIndex());
const ICONS_URL = `/icons/index.json?v=${hash(ICONS)}`;
/// The shared stylesheet, minified. @swc/html minifies CSS inside a page, so it goes through as
/// one; the doctype only keeps it from complaining that a fragment has none.
export function buildStyle({ minify = true } = {}) {
@@ -61,6 +78,7 @@ export function buildPage(name, { minify = true } = {}) {
const { script, src: files } = PAGES[name];
const src = files.map(f => fs.readFileSync(path.join(here, 'src', f + '.ts'), 'utf8')).join('\n')
.replaceAll('__HLS_JS__', HLS_URL)
.replaceAll('__ICONS__', ICONS_URL)
.replaceAll('__WEB_VERSION__', WEB_VERSION);
const js = swc.transformSync(src, {
filename: name + '.ts',
@@ -109,6 +127,12 @@ if (process.argv[1] === fileURLToPath(import.meta.url)) {
['apple-touch-icon.png', 'apple-touch-icon.png'], ['apple-touch-icon.png', 'apple-touch-icon-precomposed.png'],
['ipodderx-icon.png', 'icon.png'], ['boot.js', 'boot.js'], ['InterVariable.woff2', 'inter.woff2'], ['Inter-LICENSE.txt', 'Inter-LICENSE.txt'],
]) fs.copyFileSync(path.join(here, from), path.join(out, to));
for (const style of FA_STYLES) {
fs.mkdirSync(path.join(out, 'icons', style), { recursive: true });
for (const f of fs.readdirSync(path.join(FA, 'svgs', style))) fs.copyFileSync(path.join(FA, 'svgs', style, f), path.join(out, 'icons', style, f));
}
fs.writeFileSync(path.join(out, 'icons', 'index.json'), ICONS);
fs.copyFileSync(path.join(FA, 'LICENSE.txt'), path.join(out, 'icons', 'LICENSE.txt'));
for (const name of Object.keys(PAGES)) {
const { html, js, script } = buildPage(name);
fs.writeFileSync(path.join(out, name), html);

View File

@@ -30,6 +30,15 @@ function feedRow(id: string, cls: string, html: string, open: ()=>void){
el.onclick=()=>{ open(); nav(false); };
return el;
}
/// A Signal's icon, drawn from /icons as a mask so it takes the theme's colour, or its initials.
/// The name goes into an address and a style: only a style and a plain name are drawn (the server
/// takes nothing else either).
const ICON_ID=/^(solid|regular|brands)\/[a-z0-9-]{1,64}$/;
const iconHTML=(icon: string)=>`<span class="ficon" style="--icon:url('/icons/${icon}.svg')"></span>`;
function signalTile(sg: {name: string, icon: string|null}){
return sg.icon&&ICON_ID.test(sg.icon)
? `<div class="art ini sig" style="--tint:${tint(sg.name)}">${iconHTML(sg.icon)}</div>` : tileHTML(sg.name);
}
const badgeHTML=(n: number)=>`<span class="badge${n?'':' zero'}" title="${n} unread">${n>999?'999+':n}</span>`;
function signalFolder(list: HTMLElement, q: string){
const mine=S.signals.filter(s=>!q||s.name.toLowerCase().includes(q));
@@ -48,7 +57,7 @@ function signalFolder(list: HTMLElement, q: string){
if(!open) return;
for(const sg of mine){
const el=feedRow(':signal:'+sg.id,'child',
`<span class="fart">${tileHTML(sg.name)}</span><div class="txt"><b>${esc(sg.name)}</b><small>${esc(sg.keywords.join(', '))}</small></div>`+badgeHTML(sg.unread),
`<span class="fart">${signalTile(sg)}</span><div class="txt"><b>${esc(sg.name)}</b><small>${esc(sg.keywords.join(', '))}</small></div>`+badgeHTML(sg.unread),
()=>selectFeed(':signal:'+sg.id));
el.onpointerenter=el.ontouchstart=()=>prefetch(':signal:'+sg.id);
list.appendChild(el);
@@ -84,7 +93,7 @@ function renderSignals(){
for(const sg of S.signals){
const el=document.createElement('div');
el.className='childrow';
el.innerHTML=tileHTML(sg.name)+`<div class="txt"><b>${esc(sg.name)}</b>`+
el.innerHTML=signalTile(sg)+`<div class="txt"><b>${esc(sg.name)}</b>`+
`<small class="meta">${esc(sg.keywords.join(', '))}</small></div><span class="badge${sg.unread?'':' zero'}">${sg.unread}</span>`;
el.onclick=()=>selectFeed(':signal:'+sg.id);
list.appendChild(el);
@@ -95,7 +104,7 @@ function signalHead(sg: Signal){
const nokey=S.me&&!S.me.typesafe_key_set;
return `
<div class="fhead slim">
<div class="art">${ICON.signal}</div>
${signalTile(sg)}
<div class="meta">
<h2>${esc(sg.name)}</h2>
<div class="sub">Picked out of the noise of your ${plural(S.feeds.length,'feed')}: ${esc(sg.description||sg.keywords.join(', '))} · ${sg.unread} unread</div>
@@ -124,7 +133,14 @@ function signalModal(sg?: Signal){
const nokey=S.me&&!S.me.typesafe_key_set;
openModal(`<h3>${sg?'Edit signal':'New signal'}</h3>
<div class="field"><label for="sgname">Name</label>
<input type="text" id="sgname" value="${esc(sg?.name||'')}" placeholder="Formula 1"></div>
<div class="inline"><button type="button" class="iconpick" id="sgicon" title="Choose an icon" aria-label="Choose an icon"
aria-expanded="false"></button>
<input type="text" id="sgname" value="${esc(sg?.name||'')}" placeholder="Formula 1"></div>
<div id="sgpicker" hidden>
<div class="inline"><input type="search" id="sgiconq" placeholder="Search icons: robot, car, music…" autocomplete="off">
<button type="button" class="dlink" id="sgnoicon">Initials</button></div>
<div class="icongrid" id="sgicons" role="listbox" aria-label="Icons"></div>
</div></div>
<div class="field"><label for="sgkwin">Listen for</label>
<div class="chips" id="sgkw">${chipsHTML(sg?.keywords||[],'sgkwin')}</div></div>
<div class="field"><label for="sgdesc">In your own words (optional)</label>
@@ -137,12 +153,38 @@ function signalModal(sg?: Signal){
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Cancel" aria-label="Cancel">${ICON.close}</button>
<button class="btn ico primary" id="sgsave" title="${sg?'Save':'Make signal'}" aria-label="${sg?'Save':'Make signal'}">${sg?ICON.check:ICON.plus}</button></div>`);
wireChips($('#sgkw'));
let icon=sg?.icon??null;
const drawPick=()=>{ $('#sgicon').innerHTML=signalTile({name:$<HTMLInputElement>('#sgname').value||'?',icon}); };
drawPick();
$<HTMLInputElement>('#sgname').oninput=drawPick;
const grid=$('#sgicons');
const drawGrid=async()=>{
const all=await iconList();
const shown=iconMatches(all,$<HTMLInputElement>('#sgiconq').value,ICON_CELLS);
grid.innerHTML=shown.length ? shown.map(([id,label])=>`<button type="button" class="iconcell" role="option" data-ic="${id}"
aria-selected="${id===icon}" title="${esc(label)}" aria-label="${esc(label)}">${iconHTML(id)}</button>`).join('')
: '<p class="hint">No icon matches that.</p>';
};
$<HTMLButtonElement>('#sgicon').onclick=()=>{
const p=$('#sgpicker'), open=p.hidden;
p.hidden=!open; $('#sgicon').setAttribute('aria-expanded',String(open));
if(open){ drawGrid().catch(e=>toast(why(e),true)); $<HTMLInputElement>('#sgiconq').focus(); }
};
$<HTMLInputElement>('#sgiconq').oninput=()=>{ drawGrid().catch(()=>{}); };
grid.onclick=(e: MouseEvent)=>{
const b=(e.target as Element).closest<HTMLElement>('[data-ic]'); if(!b) return;
icon=b.dataset.ic!; drawPick(); $('#sgpicker').hidden=true; $('#sgicon').setAttribute('aria-expanded','false');
};
$<HTMLButtonElement>('#sgnoicon').onclick=()=>{ icon=null; drawPick(); $('#sgpicker').hidden=true; };
$<HTMLInputElement>('#sgname').focus();
$<HTMLButtonElement>('#sgsave').onclick=async()=>{
const name=$<HTMLInputElement>('#sgname').value.trim(), keywords=chipWords($('#sgkw'));
const name=$<HTMLInputElement>('#sgname').value.trim(), keywords=chipWords($('#sgkw')).filter((w): w is string=>!!w);
if(!name){ $<HTMLInputElement>('#sgname').focus(); return; }
if(!keywords.length){ $<HTMLInputElement>('#sgkwin').focus(); return; }
const body=JSON.stringify({name,keywords,description:$<HTMLInputElement>('#sgdesc').value.trim()||null});
const description=$<HTMLInputElement>('#sgdesc').value.trim()||null;
// Only what changed: a new name, words or sentence has Jev read everything again, and saving
// a new icon alone did that too while the dialog sent them all back each time.
const body=JSON.stringify(sg ? signalChanges(sg,{name,keywords,description,icon}) : {name,keywords,description,icon});
try{
const r=sg ? await api(`/api/signals/${sg.id}`,{method:'PATCH',body})
: await api('/api/signals',{method:'POST',body});
@@ -153,3 +195,38 @@ function signalModal(sg?: Signal){
}catch(e){ toast(why(e),true); }
};
}
/// What differs between a Signal and the dialog's fields, as PATCH takes it.
function signalChanges(sg: Signal, now: {name: string, keywords: string[], description: string|null, icon: string|null}){
const out: Record<string, unknown>={};
if(now.name!==sg.name) out.name=now.name;
if(now.keywords.join('\n')!==sg.keywords.join('\n')) out.keywords=now.keywords;
if(now.description!==sg.description) out.description=now.description;
if(now.icon!==sg.icon) out.icon=now.icon;
return out;
}
/// Font Awesome Free's index, `[style/name, label, terms]`, fetched once, when the picker first
/// opens: 2,883 icons are about 90 KB compressed, which the page has no other use for.
const ICON_CELLS=120;
let icons: Promise<[string,string,string][]>|null=null;
function iconList(){
if(!icons) icons=api('__ICONS__').catch((e: unknown)=>{ icons=null; throw e; });
return icons!;
}
/// The icons with a word starting with each word typed, in their name, label or search terms. The
/// first word typed ranks them: the icon named it, then one with it as a word of its name, then as
/// a search term, then the rest. Matching anywhere in a word, "car" filled the grid with caret and
/// cart (#261). At most `max`; nothing typed is the first `max`.
function iconMatches(all: [string,string,string][], q: string, max: number){
const words=q.toLowerCase().split(/\s+/).filter(Boolean);
if(!words.length) return all.slice(0,max);
const split=(s: string)=>s.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
const rank=([id,label,terms]: [string,string,string])=>{
const name=id.slice(id.indexOf('/')+1), own=split(name), hay=[...own,...split(label),...split(terms)];
if(!words.every(w=>hay.some(h=>h.startsWith(w)))) return -1;
const w=words[0];
return name===w ? 0 : own.includes(w) ? 1 : hay.includes(w) ? 2 : 3;
};
return all.map(i=>[rank(i),i] as const).filter(([r])=>r>=0).sort((a,b)=>a[0]-b[0]).slice(0,max).map(([,i])=>i);
}

View File

@@ -93,6 +93,8 @@ interface Signal {
keywords: string[];
/// What its owner means by it, in their words (#259).
description: string | null;
/// A Font Awesome Free icon, `solid/robot`, in place of its initials (#261).
icon: string | null;
unread: number;
}