~ knotless Search/
↑↓ move↵ open⇧↵ all results

grain.social/grain

photography-focused photo sharing platform

grain.social
1 collaborator
0
2

grain-serve lists notifications and counts the unread, as Bluesky does

Merged chadtmiller.com asked to merge into feature/rust from grain.social/grain:notifications/list

Only what this pull adds. It is built on grain runs on a Rust server modelled on knotless, without hatk.

crates/grain-serve/src/index/schema.rs+65−1
@@ -40,6 +40,14 @@
//! them, since a record's own rowid may change on a `VACUUM`. They are
//! made from the records, so rebuilding these rebuilds them.
//!
//! # Mentions
//!
//! `mention` is each account a comment or a gallery mentions, a row each:
//! a facet feature of `app.bsky.richtext.facet#mention`, by its `did`.
//! Triggers on `comment` and `gallery` keep it, and it is made from the
//! records, so notifications find a mention by its account's DID rather
//! than by reading every record's facets.
//!
//! `record_rev` is the revision each record was last written or deleted
//! at (`gone`), for backfill to leave alone what is newer than the repo it
//! read: a write the stream applied after the repo was exported, or a
@@ -77,7 +85,7 @@
/// Each step of the schema, run once, in order, by `PRAGMA user_version`.
/// A step is only ever appended: one that has run is never changed.
pub const MIGRATIONS: &[&str] = &[V1, V2, V3, V4, V5];
pub const MIGRATIONS: &[&str] = &[V1, V2, V3, V4, V5, V6];
const V1: &str = "
CREATE TABLE gallery (
@@ -362,6 +370,62 @@ INSERT INTO notification_pref (did, kind, push, in_app, from_follows)
DELETE FROM preference WHERE key IN ('lastSeenNotifications', 'notificationPrefs');
";
/// The accounts comments and galleries mention, kept by triggers, and
/// filled from what the index holds.
const V6: &str = "
CREATE TABLE mention (did TEXT NOT NULL, uri TEXT NOT NULL, PRIMARY KEY (did, uri)) WITHOUT ROWID;
CREATE INDEX mention_uri ON mention (uri);
CREATE TRIGGER comment_mention_insert AFTER INSERT ON comment BEGIN
INSERT OR IGNORE INTO mention (did, uri)
SELECT json_extract(NEW.facets, t.path || '.did'), NEW.uri
FROM json_tree(CASE WHEN json_valid(NEW.facets) THEN NEW.facets END) t
WHERE t.key = '$type' AND t.atom = 'app.bsky.richtext.facet#mention'
AND json_type(NEW.facets, t.path || '.did') = 'text';
END;
CREATE TRIGGER comment_mention_update AFTER UPDATE ON comment BEGIN
DELETE FROM mention WHERE uri = OLD.uri;
INSERT OR IGNORE INTO mention (did, uri)
SELECT json_extract(NEW.facets, t.path || '.did'), NEW.uri
FROM json_tree(CASE WHEN json_valid(NEW.facets) THEN NEW.facets END) t
WHERE t.key = '$type' AND t.atom = 'app.bsky.richtext.facet#mention'
AND json_type(NEW.facets, t.path || '.did') = 'text';
END;
CREATE TRIGGER comment_mention_delete AFTER DELETE ON comment BEGIN
DELETE FROM mention WHERE uri = OLD.uri;
END;
CREATE TRIGGER gallery_mention_insert AFTER INSERT ON gallery BEGIN
INSERT OR IGNORE INTO mention (did, uri)
SELECT json_extract(NEW.facets, t.path || '.did'), NEW.uri
FROM json_tree(CASE WHEN json_valid(NEW.facets) THEN NEW.facets END) t
WHERE t.key = '$type' AND t.atom = 'app.bsky.richtext.facet#mention'
AND json_type(NEW.facets, t.path || '.did') = 'text';
END;
CREATE TRIGGER gallery_mention_update AFTER UPDATE ON gallery BEGIN
DELETE FROM mention WHERE uri = OLD.uri;
INSERT OR IGNORE INTO mention (did, uri)
SELECT json_extract(NEW.facets, t.path || '.did'), NEW.uri
FROM json_tree(CASE WHEN json_valid(NEW.facets) THEN NEW.facets END) t
WHERE t.key = '$type' AND t.atom = 'app.bsky.richtext.facet#mention'
AND json_type(NEW.facets, t.path || '.did') = 'text';
END;
CREATE TRIGGER gallery_mention_delete AFTER DELETE ON gallery BEGIN
DELETE FROM mention WHERE uri = OLD.uri;
END;
INSERT OR IGNORE INTO mention (did, uri)
SELECT json_extract(c.facets, t.path || '.did'), c.uri
FROM comment c, json_tree(CASE WHEN json_valid(c.facets) THEN c.facets END) t
WHERE t.key = '$type' AND t.atom = 'app.bsky.richtext.facet#mention'
AND json_type(c.facets, t.path || '.did') = 'text';
INSERT OR IGNORE INTO mention (did, uri)
SELECT json_extract(g.facets, t.path || '.did'), g.uri
FROM gallery g, json_tree(CASE WHEN json_valid(g.facets) THEN g.facets END) t
WHERE t.key = '$type' AND t.atom = 'app.bsky.richtext.facet#mention'
AND json_type(g.facets, t.path || '.did') = 'text';
";
/// The label values that leave what carries them out of feeds: the
/// imperative ones, and those whose default is to hide.
pub const HIDDEN_LABELS: [&str; 9] = [
crates/grain-serve/src/read/mod.rs+9−0
@@ -21,6 +21,7 @@ pub mod gallery;
pub mod graph;
pub mod location;
pub mod moderation;
pub mod notification;
pub mod place;
pub mod search;
pub mod story;
@@ -83,6 +84,14 @@ pub const METHODS: &[(&str, Method)] = &[
),
("social.grain.gallery.getCameras", camera::get_cameras),
("social.grain.gallery.getLocations", location::get_locations),
(
"social.grain.notification.listNotifications",
notification::list_notifications,
),
(
"social.grain.notification.getUnreadCount",
notification::get_unread_count,
),
];
/// `router` with each of [`METHODS`] at `/xrpc/<nsid>`, and typeahead,
crates/grain-serve/src/read/notification.rsnew+415−0
415 lines changed: not loaded until asked for.
crates/grain-serve/src/read/tests.rs+1−0
@@ -14,6 +14,7 @@ fn the_reads_read_by_index() {
queries.extend(feed::queries());
queries.extend(views::queries());
queries.extend(search::queries());
queries.extend(notification::queries());
index.read(|db| {
sized(db);
for sql in &queries {
crates/grain-serve/tests/notifications.rs+573−1
574 lines changed: not loaded until asked for.
lexicons/social/grain/notification/defs.json+10−1
@@ -4,7 +4,7 @@
"defs": {
"notificationItem": {
"type": "object",
"required": ["uri", "reason", "createdAt", "author"],
"required": ["uri", "reason", "createdAt", "indexedAt", "isRead", "author"],
"properties": {
"uri": { "type": "string", "format": "at-uri" },
"reason": {
@@ -22,6 +22,15 @@
]
},
"createdAt": { "type": "string", "format": "datetime" },
"indexedAt": {
"type": "string",
"format": "datetime",
"description": "When the index first held the record, or when it says it was made if that is earlier: what notifications are ordered by, and isRead and getUnreadCount compare with seenAt."
},
"isRead": {
"type": "boolean",
"description": "Whether the viewer had marked their notifications seen as of indexedAt."
},
"author": { "type": "ref", "ref": "social.grain.actor.defs#profileView" },
"galleryUri": { "type": "string", "format": "at-uri" },
"galleryTitle": { "type": "string" },
lexicons/social/grain/notification/listNotifications.json+6−2
@@ -4,7 +4,7 @@
"defs": {
"main": {
"type": "query",
"description": "Get notifications for the authenticated user.",
"description": "Get the authenticated user's notifications, the latest first, as app.bsky.notification.listNotifications does. Requires auth.",
"parameters": {
"type": "params",
"properties": {
@@ -23,7 +23,11 @@
"items": { "type": "ref", "ref": "social.grain.notification.defs#notificationItem" }
},
"cursor": { "type": "string" },
"unseenCount": { "type": "integer" }
"seenAt": {
"type": "string",
"format": "datetime",
"description": "When the viewer last marked their notifications seen (updateSeen), if they have."
}
}
}
}
lexicons/README.md+5−0
@@ -68,6 +68,11 @@ itself, or inline, are named defs in the namespace's `defs.json`.
| `getStory` | `social.grain.story.getStory` |
| `markStoriesViewed` | `social.grain.story.markViewed` |
The notifications are shaped after Bluesky's, so they differ from hatk's
`getNotifications`: the count is a method of its own, and the list says
the viewer's `seenAt` and each notification's `indexedAt` and `isRead`,
where hatk's said `unseenCount`.
### `dev.hatk`
| Was | Is |
hatk.generated.ts+2−2
@@ -124,9 +124,9 @@ const listFeedLex = {"lexicon":1,"id":"social.grain.group.pool.listFeed","defs":
const listGalleriesLex = {"lexicon":1,"id":"social.grain.group.pool.listGalleries","defs":{"main":{"type":"query","description":"Every gallery in a group's pool, newest first. Assembled at request time from the repos of the members who wrote them — a pool space never reaches a firehose, so none of it is in the index — and readable only by a member the group's host issues a credential to.","parameters":{"type":"params","required":["group"],"properties":{"group":{"type":"string","format":"did"},"limit":{"type":"integer","minimum":1,"maximum":100,"default":50}}},"output":{"encoding":"application/json","schema":{"type":"object","required":["space","galleries"],"properties":{"space":{"type":"string"},"galleries":{"type":"array","items":{"type":"ref","ref":"social.grain.group.pool.defs#poolGallery"}}}}},"errors":[{"name":"NotAuthorized","description":"The viewer is not a member of this pool."}]}}} as const
const putFavoriteLex = {"lexicon":1,"id":"social.grain.group.pool.putFavorite","defs":{"main":{"type":"procedure","description":"Favourite, or unfavourite, a gallery in a group's pool. The record is the viewer's own, written into the pool rather than their public repo: in the public repo it would name a private gallery to the whole network.","input":{"encoding":"application/json","schema":{"type":"object","required":["group","gallery","on"],"properties":{"group":{"type":"string","format":"did"},"gallery":{"type":"string","format":"at-uri","description":"The gallery being favourited, by the uri it would have in its author's repo."},"on":{"type":"boolean"}}}},"output":{"encoding":"application/json","schema":{"type":"object","required":["on"],"properties":{"on":{"type":"boolean"},"rkey":{"type":"string","description":"The favourite record, when there is one."}}}},"errors":[{"name":"NotAuthorized","description":"The viewer is not a member of this pool."}]}}} as const
const submissionLex = {"lexicon":1,"id":"social.grain.group.submission","defs":{"main":{"type":"record","key":"tid","description":"A member offering one of their own galleries to a group's pool. Lives in the member's repo; the group answers with a social.grain.group.item.","record":{"type":"object","required":["group","gallery","createdAt"],"properties":{"group":{"type":"string","format":"did","description":"The group (a group DID)."},"gallery":{"type":"string","format":"at-uri","description":"One of the submitter's galleries."},"createdAt":{"type":"string","format":"datetime"}}}}}} as const
const grainNotificationDefsLex = {"lexicon":1,"id":"social.grain.notification.defs","defs":{"notificationItem":{"type":"object","required":["uri","reason","createdAt","author"],"properties":{"uri":{"type":"string","format":"at-uri"},"reason":{"type":"string","knownValues":["gallery-favorite","gallery-comment","gallery-comment-mention","gallery-mention","comment-favorite","story-favorite","story-comment","reply","follow"]},"createdAt":{"type":"string","format":"datetime"},"author":{"type":"ref","ref":"social.grain.actor.defs#profileView"},"galleryUri":{"type":"string","format":"at-uri"},"galleryTitle":{"type":"string"},"galleryThumb":{"type":"string"},"storyUri":{"type":"string","format":"at-uri"},"storyThumb":{"type":"string"},"commentText":{"type":"string"},"replyToText":{"type":"string"}}}}} as const
const grainNotificationDefsLex = {"lexicon":1,"id":"social.grain.notification.defs","defs":{"notificationItem":{"type":"object","required":["uri","reason","createdAt","indexedAt","isRead","author"],"properties":{"uri":{"type":"string","format":"at-uri"},"reason":{"type":"string","knownValues":["gallery-favorite","gallery-comment","gallery-comment-mention","gallery-mention","comment-favorite","story-favorite","story-comment","reply","follow"]},"createdAt":{"type":"string","format":"datetime"},"indexedAt":{"type":"string","format":"datetime","description":"When the index first held the record, or when it says it was made if that is earlier: what notifications are ordered by, and isRead and getUnreadCount compare with seenAt."},"isRead":{"type":"boolean","description":"Whether the viewer had marked their notifications seen as of indexedAt."},"author":{"type":"ref","ref":"social.grain.actor.defs#profileView"},"galleryUri":{"type":"string","format":"at-uri"},"galleryTitle":{"type":"string"},"galleryThumb":{"type":"string"},"storyUri":{"type":"string","format":"at-uri"},"storyThumb":{"type":"string"},"commentText":{"type":"string"},"replyToText":{"type":"string"}}}}} as const
const getUnreadCountLex = {"lexicon":1,"id":"social.grain.notification.getUnreadCount","defs":{"main":{"type":"query","description":"Count the viewer's notifications since they were last seen, as app.bsky.notification.getUnreadCount does. Requires auth.","output":{"encoding":"application/json","schema":{"type":"object","required":["count"],"properties":{"count":{"type":"integer"}}}}}}} as const
const listNotificationsLex = {"lexicon":1,"id":"social.grain.notification.listNotifications","defs":{"main":{"type":"query","description":"Get notifications for the authenticated user.","parameters":{"type":"params","properties":{"limit":{"type":"integer","minimum":1,"maximum":100,"default":20},"cursor":{"type":"string"}}},"output":{"encoding":"application/json","schema":{"type":"object","required":["notifications"],"properties":{"notifications":{"type":"array","items":{"type":"ref","ref":"social.grain.notification.defs#notificationItem"}},"cursor":{"type":"string"},"unseenCount":{"type":"integer"}}}}}}} as const
const listNotificationsLex = {"lexicon":1,"id":"social.grain.notification.listNotifications","defs":{"main":{"type":"query","description":"Get the authenticated user's notifications, the latest first, as app.bsky.notification.listNotifications does. Requires auth.","parameters":{"type":"params","properties":{"limit":{"type":"integer","minimum":1,"maximum":100,"default":20},"cursor":{"type":"string"}}},"output":{"encoding":"application/json","schema":{"type":"object","required":["notifications"],"properties":{"notifications":{"type":"array","items":{"type":"ref","ref":"social.grain.notification.defs#notificationItem"}},"cursor":{"type":"string"},"seenAt":{"type":"string","format":"datetime","description":"When the viewer last marked their notifications seen (updateSeen), if they have."}}}}}}} as const
const registerPushLex = {"lexicon":1,"id":"social.grain.notification.registerPush","defs":{"main":{"type":"procedure","description":"Register a device's push token for the viewer, as app.bsky.notification.registerPush does. Requires auth.","input":{"encoding":"application/json","schema":{"type":"object","required":["serviceDid","token","platform","appId"],"properties":{"serviceDid":{"type":"string","format":"did","description":"The service that sends the pushes: this server's DID."},"token":{"type":"string"},"platform":{"type":"string","knownValues":["ios","android","web"]},"appId":{"type":"string","description":"The app's bundle or package id."}}}},"output":{"encoding":"application/json","schema":{"type":"object","properties":{}}}}}} as const
const unregisterPushLex = {"lexicon":1,"id":"social.grain.notification.unregisterPush","defs":{"main":{"type":"procedure","description":"Stop sending pushes to a device token, as app.bsky.notification.unregisterPush does. Requires auth.","input":{"encoding":"application/json","schema":{"type":"object","required":["serviceDid","token","platform","appId"],"properties":{"serviceDid":{"type":"string","format":"did","description":"The service that sends the pushes: this server's DID."},"token":{"type":"string"},"platform":{"type":"string","knownValues":["ios","android","web"]},"appId":{"type":"string","description":"The app's bundle or package id."}}}},"output":{"encoding":"application/json","schema":{"type":"object","properties":{}}}}}} as const
const updateSeenLex = {"lexicon":1,"id":"social.grain.notification.updateSeen","defs":{"main":{"type":"procedure","description":"Mark the viewer's notifications seen up to a time, as app.bsky.notification.updateSeen does. getUnreadCount counts from it. Requires auth.","input":{"encoding":"application/json","schema":{"type":"object","required":["seenAt"],"properties":{"seenAt":{"type":"string","format":"datetime"}}}},"output":{"encoding":"application/json","schema":{"type":"object","properties":{}}}}}} as const