grain.social is a photo sharing platform built on atproto. grain.social
atproto photography appview
57
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add Germ DM button on profiles

- Add com.germnetwork.declaration lexicon for record indexing
- Hydrate messageMe in getActorProfile response
- Show "Germ DM" pill with logo on profiles based on showButtonTo policy
- Profile owners always see their own button
- Bump hatk to alpha.50 for Uint8Array bytes validation fix
- Add germ declaration seed data for Alice

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

+291 -9
+23
app/routes/profile/[did]/+page.svelte
··· 33 33 ...knownFollowersQuery(did, viewerDid ?? ''), 34 34 enabled: !!viewerDid && viewerDid !== did, 35 35 })) 36 + 37 + const showGermButton = $derived.by(() => { 38 + const p = profile.data as any 39 + if (!p?.messageMe || !viewerDid) return false 40 + if (isOwnProfile) return true 41 + const policy = p.messageMe.showButtonTo 42 + if (policy === 'everyone') return true 43 + if (policy === 'usersIFollow') return !!p.viewer?.followedBy 44 + return false 45 + }) 46 + const germUrl = $derived.by(() => { 47 + const p = profile.data as any 48 + if (!p?.messageMe?.messageMeUrl || !viewerDid) return null 49 + return `${p.messageMe.messageMeUrl}#${viewerDid},${did}` 50 + }) 36 51 </script> 37 52 38 53 {#if profile.isLoading} ··· 81 96 <a class="link-pill" href="https://bsky.app/profile/{p.handle || did}" target="_blank" rel="noopener noreferrer"> 82 97 Bluesky <ExternalLink size={12} /> 83 98 </a> 99 + {#if showGermButton && germUrl} 100 + <a class="link-pill germ-pill" href={germUrl} target="_blank" rel="noopener noreferrer"> 101 + <img src="/germ-logo.png" alt="" class="germ-logo" /> Germ DM 102 + </a> 103 + {/if} 84 104 </div> 85 105 {#if (knownFollowers.data?.items ?? []).length > 0} 86 106 {@const known = knownFollowers.data?.items ?? []} ··· 189 209 font-size: 13px; font-weight: 500; color: var(--text-secondary); transition: all 0.12s; 190 210 } 191 211 .link-pill:hover { background: var(--bg-hover); color: var(--text-primary); } 212 + .germ-pill { color: var(--grain); border-color: var(--grain); } 213 + .germ-pill:hover { color: var(--text-primary); } 214 + .germ-logo { width: 14px; height: 14px; object-fit: contain; } 192 215 .view-toggle { 193 216 display: flex; 194 217 justify-content: center;
+14 -2
db/schema.sql
··· 67 67 CREATE TABLE _oauth_sessions ( 68 68 did TEXT PRIMARY KEY, 69 69 pds_endpoint TEXT NOT NULL, 70 + pds_auth_server TEXT, 70 71 access_token TEXT NOT NULL, 71 72 refresh_token TEXT, 72 73 dpop_jkt TEXT NOT NULL, 73 74 token_expires_at INTEGER, 74 75 created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 75 - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 76 - pds_auth_server TEXT 76 + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP 77 77 ); 78 78 79 79 CREATE TABLE _preferences ( ··· 218 218 indexed_at TEXT NOT NULL, 219 219 subject TEXT NOT NULL, 220 220 created_at TEXT NOT NULL 221 + ); 222 + 223 + CREATE TABLE "com.germnetwork.declaration" ( 224 + uri TEXT PRIMARY KEY, 225 + cid TEXT, 226 + did TEXT NOT NULL, 227 + indexed_at TEXT NOT NULL, 228 + version TEXT NOT NULL, 229 + current_key BLOB NOT NULL, 230 + message_me TEXT, 231 + key_package BLOB, 232 + continuity_proofs TEXT 221 233 ); 222 234 223 235 CREATE TABLE "social.grain.actor.profile" (
+154
docs/plans/2026-04-01-push-notifications-design.md
··· 1 + # Push Notifications Design 2 + 3 + ## Overview 4 + 5 + Add push notification support across three repos: hatk gains an `on-commit` hook primitive and push delivery infrastructure, grain wires notification events to push, and grain-native registers for and handles APNs. 6 + 7 + ## hatk: `on-commit` Hook Primitive 8 + 9 + A new hook trigger type that fires after the firehose indexer writes a record to the database. Follows the existing `defineHook(event, handler)` pattern with an options object for declarative collection filtering. 10 + 11 + ```ts 12 + // server/hooks/on-commit-favorite.ts 13 + export default defineHook("on-commit", { collections: ["social.grain.favorite"] }, 14 + async ({ action, collection, record, repo, uri, db, lookup, push }) => { 15 + // action: "create" | "delete" 16 + // collection: NSID that matched 17 + // record: the record value 18 + // repo: DID of the committing actor 19 + // uri: AT URI of the record 20 + // db: { query, run } — raw SQL access 21 + // lookup: typed record lookup (same as BaseContext) 22 + // push: push delivery interface 23 + } 24 + ) 25 + ``` 26 + 27 + The firehose indexer already processes commits in `flushBuffer()`. After `insertRecord` / `deleteRecord`, it invokes matching `on-commit` hooks filtered by `collections`. Multiple hooks can match the same commit. Hooks run async and non-blocking (same pattern as `runLabelRules`). 28 + 29 + ## hatk: Push Token Registration & Storage 30 + 31 + ### Database 32 + 33 + ```sql 34 + CREATE TABLE _push_tokens ( 35 + did TEXT NOT NULL, 36 + token TEXT NOT NULL, 37 + platform TEXT NOT NULL, -- "apns" for now, "fcm"/"web" later 38 + createdAt TEXT NOT NULL, 39 + PRIMARY KEY (did, token) 40 + ); 41 + ``` 42 + 43 + ### Built-in XRPC Endpoints 44 + 45 + - `social.hatk.push.registerToken` — accepts `{ token, platform }`, associates with authenticated viewer's DID. Handles duplicates via upsert. 46 + - `social.hatk.push.unregisterToken` — accepts `{ token }`, removes it. 47 + 48 + ### Configuration 49 + 50 + ```ts 51 + // hatk.config.ts 52 + push: { 53 + apns: { 54 + keyFile: "./certs/AuthKey_XXXX.p8", 55 + keyId: "XXXX", 56 + teamId: "YYYY", 57 + bundleId: "com.grain.app", 58 + } 59 + } 60 + ``` 61 + 62 + Push features are disabled entirely if no `push` config is present. 63 + 64 + ## hatk: `push.send()` Delivery 65 + 66 + The `push` object is injected into hook handler context: 67 + 68 + ```ts 69 + await push.send({ 70 + did: string, // recipient's DID 71 + title: string, // notification title 72 + body: string, // notification body 73 + data?: Record<string, string>, // custom payload (e.g. { uri, type }) 74 + collapseId?: string, // APNs collapse-id for grouping 75 + }) 76 + ``` 77 + 78 + Internals: 79 + 80 + - Looks up all tokens for the DID in `_push_tokens` 81 + - Builds APNs payload, sends via HTTP/2 to Apple's push gateway 82 + - If Apple responds with invalid/expired token, deletes it from `_push_tokens` (self-cleaning) 83 + - Fire-and-forget — delivery failures are logged via `emit()` but don't throw 84 + - No queuing for v1. Hooks run async after flush (like label rules). A queue can be added behind the same API later. 85 + 86 + ## grain: Hook Implementations 87 + 88 + One `on-commit` hook file per notification trigger: 89 + 90 + ``` 91 + server/hooks/ 92 + on-login.ts (existing) 93 + on-commit-favorite.ts (new) 94 + on-commit-comment.ts (new) 95 + on-commit-follow.ts (new) 96 + ``` 97 + 98 + ### Example: Favorites 99 + 100 + ```ts 101 + export default defineHook("on-commit", { collections: ["social.grain.favorite"] }, 102 + async ({ action, record, repo, db, lookup, push }) => { 103 + if (action !== "create") return 104 + const [gallery] = await db.query( 105 + `SELECT did AS author, uri FROM "social.grain.gallery" WHERE uri = $1`, 106 + [record.subject] 107 + ) 108 + if (!gallery || gallery.author === repo) return 109 + const profiles = await lookup("app.bsky.actor.profile", "did", [repo]) 110 + const actor = profiles.get(repo) 111 + await push.send({ 112 + did: gallery.author, 113 + title: "New favorite", 114 + body: `${actor?.value.displayName ?? "Someone"} favorited your gallery`, 115 + data: { type: "gallery-favorite", uri: gallery.uri }, 116 + }) 117 + } 118 + ) 119 + ``` 120 + 121 + Comments and follows follow the same pattern. Mentions and replies parse facets or look up parent comment authors but use the same shape. 122 + 123 + The existing `getNotifications` query is unchanged — it remains the source of truth for the notification list. Push alerts the user to come look. 124 + 125 + ## grain-native: APNs Registration & Handling 126 + 127 + ### Token Registration Flow 128 + 129 + 1. On app launch (after auth), call `UNUserNotificationCenter.requestAuthorization()` 130 + 2. On grant, call `UIApplication.shared.registerForRemoteNotifications()` 131 + 3. In `AppDelegate.didRegisterForRemoteNotificationsWithDeviceToken`, send the hex-encoded token to `social.hatk.push.registerToken` via XRPC client 132 + 4. Re-register on every app launch (token can change; server upserts) 133 + 134 + ### Handling Incoming Pushes 135 + 136 + - Tap on a notification reads `data.type` and `data.uri` from the payload 137 + - Routes to the appropriate view: gallery detail for favorites/comments, profile for follows 138 + 139 + ### Unregistration 140 + 141 + - On logout, call `social.hatk.push.unregisterToken` to remove the token server-side 142 + 143 + ### Entitlements 144 + 145 + - Add Push Notifications capability in Xcode 146 + - Add `aps-environment` entitlement (development/production) 147 + - Register bundle ID with APNs in Apple Developer portal 148 + 149 + ## What's NOT in v1 150 + 151 + - No FCM / web push (table has `platform` column for future use) 152 + - No queuing / retry system (async inline delivery, failures logged) 153 + - No notification preferences / muting (all types push to all registered devices) 154 + - No rich notifications (images, actions) — title + body + data only
+1 -1
hatk.generated.client.ts
··· 3 3 // to avoid pulling in server-only dependencies. 4 4 export type { XrpcSchema } from './hatk.generated.ts' 5 5 import type { XrpcSchema } from './hatk.generated.ts' 6 - export type { BskyActorProfile, Post, Postgate, Threadgate, BskyGraphFollow, CreateReport, DescribeCollections, DescribeFeeds, DescribeLabels, GetFeed, GetPreferences, GetRecord, GetRecords, PutPreference, SearchRecords, UploadBlob, GrainActorProfile, Comment, Favorite, Gallery, Item, GrainGraphFollow, Photo, Exif, Story, DeleteGallery, GetActorProfile, GetCameras, GetFollowers, GetFollowing, GetGallery, GetGalleryThread, GetKnownFollowers, GetLocations, GetNotifications, GetStories, GetStory, GetStoryArchive, GetStoryAuthors, GetSuggestedFollows, SearchGalleries, SearchProfiles, RecordRegistry, CreateRecord, DeleteRecord, PutRecord, Nux, MutedWord, SavedFeed, StatusView, BskyActorDefsProfileView, BskyActorDefsViewerState, FeedViewPref, LabelersPref, InterestsPref, KnownFollowers, MutedWordsPref, SavedFeedsPref, ThreadViewPref, DeclaredAgePref, HiddenPostsPref, LabelerPrefItem, AdultContentPref, BskyAppStatePref, ContentLabelPref, ProfileViewBasic, SavedFeedsPrefV2, VerificationView, ProfileAssociated, VerificationPrefs, VerificationState, PersonalDetailsPref, BskyActorDefsProfileViewDetailed, BskyAppProgressGuide, LiveEventPreferences, ProfileAssociatedChat, ProfileAssociatedGerm, PostInteractionSettingsPref, ProfileAssociatedActivitySubscription, BskyEmbedDefsAspectRatio, ExternalView, External, ViewExternal, ImagesView, Image, ViewImage, RecordView, ViewRecord, ViewBlocked, ViewDetached, ViewNotFound, RecordWithMediaView, VideoView, Caption, PostView, BskyFeedDefsReplyRef, ReasonPin, BlockedPost, Interaction, BskyFeedDefsViewerState, FeedViewPost, NotFoundPost, ReasonRepost, BlockedAuthor, GeneratorView, ThreadContext, ThreadViewPost, ThreadgateView, SkeletonFeedPost, SkeletonReasonPin, GeneratorViewerState, SkeletonReasonRepost, Entity, PostReplyRef, TextSlice, DisableRule, ListRule, MentionRule, FollowerRule, FollowingRule, ListView, ListItemView, Relationship, ListViewBasic, NotFoundActor, ListViewerState, StarterPackView, StarterPackViewBasic, LabelerView, LabelerPolicies, LabelerViewerState, LabelerViewDetailed, Preference, Preferences, RecordDeleted, ChatPreference, ActivitySubscription, FilterablePreference, SubjectActivitySubscription, Tag, Link, Mention, ByteSlice, Label, SelfLabels, SelfLabel, LabelValueDefinition, LabelValueDefinitionStrings, RepoRef, LabelDefinition, LabelLocale, GrainActorDefsProfileView, GrainActorDefsProfileViewDetailed, GrainActorDefsViewerState, CommentView, GrainDefsAspectRatio, GalleryView, CrossPostInfo, GrainGalleryDefsViewerState, PhotoView, ExifView, GalleryState, StoryView, CameraItem, GetFollowersFollowerItem, GetFollowersViewerState, FollowingItem, GetFollowingViewerState, GetKnownFollowersFollowerItem, LocationItem, NotificationItem, StoryAuthor, SuggestedItem, ProfileSearchResult } from './hatk.generated.ts' 6 + export type { BskyActorProfile, Post, Postgate, Threadgate, BskyGraphFollow, Declaration, CreateReport, DescribeCollections, DescribeFeeds, DescribeLabels, GetFeed, GetPreferences, GetRecord, GetRecords, PutPreference, SearchRecords, UploadBlob, GrainActorProfile, Comment, Favorite, Gallery, Item, GrainGraphFollow, Photo, Exif, Story, DeleteGallery, GetActorProfile, GetCameras, GetFollowers, GetFollowing, GetGallery, GetGalleryThread, GetKnownFollowers, GetLocations, GetNotifications, GetStories, GetStory, GetStoryArchive, GetStoryAuthors, GetSuggestedFollows, SearchGalleries, SearchProfiles, RecordRegistry, CreateRecord, DeleteRecord, PutRecord, Nux, MutedWord, SavedFeed, StatusView, BskyActorDefsProfileView, BskyActorDefsViewerState, FeedViewPref, LabelersPref, InterestsPref, KnownFollowers, MutedWordsPref, SavedFeedsPref, ThreadViewPref, DeclaredAgePref, HiddenPostsPref, LabelerPrefItem, AdultContentPref, BskyAppStatePref, ContentLabelPref, ProfileViewBasic, SavedFeedsPrefV2, VerificationView, ProfileAssociated, VerificationPrefs, VerificationState, PersonalDetailsPref, BskyActorDefsProfileViewDetailed, BskyAppProgressGuide, LiveEventPreferences, ProfileAssociatedChat, ProfileAssociatedGerm, PostInteractionSettingsPref, ProfileAssociatedActivitySubscription, BskyEmbedDefsAspectRatio, ExternalView, External, ViewExternal, ImagesView, Image, ViewImage, RecordView, ViewRecord, ViewBlocked, ViewDetached, ViewNotFound, RecordWithMediaView, VideoView, Caption, PostView, BskyFeedDefsReplyRef, ReasonPin, BlockedPost, Interaction, BskyFeedDefsViewerState, FeedViewPost, NotFoundPost, ReasonRepost, BlockedAuthor, GeneratorView, ThreadContext, ThreadViewPost, ThreadgateView, SkeletonFeedPost, SkeletonReasonPin, GeneratorViewerState, SkeletonReasonRepost, Entity, PostReplyRef, TextSlice, DisableRule, ListRule, MentionRule, FollowerRule, FollowingRule, ListView, ListItemView, Relationship, ListViewBasic, NotFoundActor, ListViewerState, StarterPackView, StarterPackViewBasic, LabelerView, LabelerPolicies, LabelerViewerState, LabelerViewDetailed, Preference, Preferences, RecordDeleted, ChatPreference, ActivitySubscription, FilterablePreference, SubjectActivitySubscription, Tag, Link, Mention, ByteSlice, Label, SelfLabels, SelfLabel, LabelValueDefinition, LabelValueDefinitionStrings, MessageMe, RepoRef, LabelDefinition, LabelLocale, GrainActorDefsProfileView, GrainActorDefsProfileViewDetailed, GrainActorDefsViewerState, CommentView, GrainDefsAspectRatio, GalleryView, CrossPostInfo, GrainGalleryDefsViewerState, PhotoView, ExifView, GalleryState, StoryView, CameraItem, GetFollowersFollowerItem, GetFollowersViewerState, FollowingItem, GetFollowingViewerState, GetKnownFollowersFollowerItem, LocationItem, NotificationItem, StoryAuthor, SuggestedItem, ProfileSearchResult } from './hatk.generated.ts' 7 7 8 8 const _procedures = new Set(['dev.hatk.createRecord', 'dev.hatk.createReport', 'dev.hatk.deleteRecord', 'dev.hatk.putPreference', 'dev.hatk.putRecord', 'social.grain.unspecced.deleteGallery']) 9 9 const _blobInputs = new Set(['dev.hatk.uploadBlob'])
+5
hatk.generated.ts
··· 27 27 const atprotoLabelDefsLex = {"lexicon":1,"id":"com.atproto.label.defs","defs":{"label":{"type":"object","description":"Metadata tag on an atproto resource (eg, repo or record).","required":["src","uri","val","cts"],"properties":{"ver":{"type":"integer"},"src":{"type":"string","format":"did"},"uri":{"type":"string","format":"uri"},"cid":{"type":"string","format":"cid"},"val":{"type":"string","maxLength":128},"neg":{"type":"boolean"},"cts":{"type":"string","format":"datetime"},"exp":{"type":"string","format":"datetime"},"sig":{"type":"bytes"}}},"selfLabels":{"type":"object","description":"Metadata tags on an atproto record, published by the author within the record.","required":["values"],"properties":{"values":{"type":"array","items":{"type":"ref","ref":"#selfLabel"},"maxLength":10}}},"selfLabel":{"type":"object","required":["val"],"properties":{"val":{"type":"string","maxLength":128}}},"labelValueDefinition":{"type":"object","description":"Declares a label value and its expected interpretations and behaviors.","required":["identifier","severity","blurs","locales"],"properties":{"identifier":{"type":"string","maxLength":100},"severity":{"type":"string","knownValues":["inform","alert","none"]},"blurs":{"type":"string","knownValues":["content","media","none"]},"defaultSetting":{"type":"string","knownValues":["ignore","warn","hide"]},"adultOnly":{"type":"boolean"},"locales":{"type":"array","items":{"type":"ref","ref":"#labelValueDefinitionStrings"}}}},"labelValueDefinitionStrings":{"type":"object","required":["lang","name","description"],"properties":{"lang":{"type":"string","format":"language"},"name":{"type":"string","maxLength":640},"description":{"type":"string","maxLength":100000}}},"labelValue":{"type":"string","knownValues":["!hide","!no-promote","!warn","!no-unauthenticated","dmca-violation","doxxing","porn","sexual","nudity","nsfl","gore"]}}} as const 28 28 const atprotoModerationDefsLex = {"lexicon":1,"id":"com.atproto.moderation.defs","defs":{"reasonType":{"type":"string","knownValues":["com.atproto.moderation.defs#reasonSpam","com.atproto.moderation.defs#reasonViolation","com.atproto.moderation.defs#reasonMisleading","com.atproto.moderation.defs#reasonSexual","com.atproto.moderation.defs#reasonRude","com.atproto.moderation.defs#reasonOther","com.atproto.moderation.defs#reasonAppeal"]},"reasonSpam":{"type":"token","description":"Spam: frequent unwanted promotion, replies, mentions."},"reasonViolation":{"type":"token","description":"Direct violation of server rules, laws, terms of service."},"reasonMisleading":{"type":"token","description":"Misleading identity, affiliation, or content."},"reasonSexual":{"type":"token","description":"Unwanted or mislabeled sexual content."},"reasonRude":{"type":"token","description":"Rude, harassing, explicit, or otherwise unwelcoming behavior."},"reasonOther":{"type":"token","description":"Reports not falling under another report category."},"reasonAppeal":{"type":"token","description":"Appeal a previously taken moderation action."},"subjectType":{"type":"string","description":"Tag describing a type of subject that might be reported.","knownValues":["account","record","chat"]}}} as const 29 29 const strongRefLex = {"lexicon":1,"id":"com.atproto.repo.strongRef","description":"A URI with a content-hash fingerprint.","defs":{"main":{"type":"object","required":["uri","cid"],"properties":{"uri":{"type":"string","format":"at-uri"},"cid":{"type":"string","format":"cid"}}}}} as const 30 + const declarationLex = {"lexicon":1,"id":"com.germnetwork.declaration","defs":{"main":{"type":"record","description":"A declaration of a Germ Network account","key":"literal:self","record":{"type":"object","required":["version","currentKey"],"properties":{"version":{"type":"string","description":"Semver version number, without pre-release or build information, for the format of opaque content","minLength":5,"maxLength":14},"currentKey":{"type":"bytes","description":"Opaque value, an ed25519 public key prefixed with a byte enum"},"messageMe":{"type":"ref","description":"Controls who can message this account","ref":"#messageMe"},"keyPackage":{"type":"bytes","description":"Opaque value, contains MLS KeyPackage(s), and other signature data, and is signed by the currentKey"},"continuityProofs":{"type":"array","description":"Array of opaque values to allow for key rolling","items":{"type":"bytes"},"maxLength":1000}}}},"messageMe":{"type":"object","required":["showButtonTo","messageMeUrl"],"properties":{"messageMeUrl":{"type":"string","description":"A URL to present to an account that does not have its own com.germnetwork.declaration record, where the app should fill in the fragment component with the DIDs of the two accounts who wish to message each other","format":"uri","minLength":1,"maxLength":2047},"showButtonTo":{"type":"string","knownValues":["none","usersIFollow","everyone"],"description":"The policy of who can message the account, this value is included in the keyPackage, but is duplicated here to allow applications to decide if they should show a 'Message on Germ' button to the viewer.","minLength":1,"maxLength":100}}}}} as const 30 31 const addressLex = {"lexicon":1,"id":"community.lexicon.location.address","defs":{"main":{"type":"object","description":"A physical location in the form of a street address.","required":["country"],"properties":{"country":{"type":"string","description":"The ISO 3166 country code. Preferably the 2-letter code.","minLength":2,"maxLength":10},"postalCode":{"type":"string","description":"The postal code of the location."},"region":{"type":"string","description":"The administrative region of the country. For example, a state in the USA."},"locality":{"type":"string","description":"The locality of the region. For example, a city in the USA."},"street":{"type":"string","description":"The street address."},"name":{"type":"string","description":"The name of the location."}}}}} as const 31 32 const geoLex = {"lexicon":1,"id":"community.lexicon.location.geo","defs":{"main":{"type":"object","description":"A physical location in the form of a WGS84 coordinate.","required":["latitude","longitude"],"properties":{"latitude":{"type":"string"},"longitude":{"type":"string"},"altitude":{"type":"string"},"name":{"type":"string","description":"The name of the location."}}}}} as const 32 33 const hthreeLex = {"lexicon":1,"id":"community.lexicon.location.hthree","defs":{"main":{"type":"object","description":"A physical location in the form of a H3 encoded location.","required":["value"],"properties":{"value":{"type":"string","description":"The h3 encoded location."},"name":{"type":"string","description":"The name of the location."}}}}} as const ··· 100 101 'com.atproto.label.defs': typeof atprotoLabelDefsLex 101 102 'com.atproto.moderation.defs': typeof atprotoModerationDefsLex 102 103 'com.atproto.repo.strongRef': typeof strongRefLex 104 + 'com.germnetwork.declaration': typeof declarationLex 103 105 'community.lexicon.location.address': typeof addressLex 104 106 'community.lexicon.location.geo': typeof geoLex 105 107 'community.lexicon.location.hthree': typeof hthreeLex ··· 158 160 export type Postgate = Prettify<LexRecord<typeof postgateLex, Registry>> 159 161 export type Threadgate = Prettify<LexRecord<typeof threadgateLex, Registry>> 160 162 export type BskyGraphFollow = Prettify<LexRecord<typeof bskyGraphFollowLex, Registry>> 163 + export type Declaration = Prettify<LexRecord<typeof declarationLex, Registry>> 161 164 export type CreateReport = Prettify<LexProcedure<typeof createReportLex, Registry>> 162 165 export type DescribeCollections = Prettify<LexQuery<typeof describeCollectionsLex, Registry>> 163 166 export type DescribeFeeds = Prettify<LexQuery<typeof describeFeedsLex, Registry>> ··· 202 205 'app.bsky.feed.postgate': Postgate 203 206 'app.bsky.feed.threadgate': Threadgate 204 207 'app.bsky.graph.follow': BskyGraphFollow 208 + 'com.germnetwork.declaration': Declaration 205 209 'social.grain.actor.profile': GrainActorProfile 206 210 'social.grain.comment': Comment 207 211 'social.grain.favorite': Favorite ··· 335 339 export type SelfLabel = Prettify<LexDef<typeof atprotoLabelDefsLex, 'selfLabel', Registry>> 336 340 export type LabelValueDefinition = Prettify<LexDef<typeof atprotoLabelDefsLex, 'labelValueDefinition', Registry>> 337 341 export type LabelValueDefinitionStrings = Prettify<LexDef<typeof atprotoLabelDefsLex, 'labelValueDefinitionStrings', Registry>> 342 + export type MessageMe = Prettify<LexDef<typeof declarationLex, 'messageMe', Registry>> 338 343 export type RepoRef = Prettify<LexDef<typeof createReportLex, 'repoRef', Registry>> 339 344 export type LabelDefinition = Prettify<LexDef<typeof describeLabelsLex, 'labelDefinition', Registry>> 340 345 export type LabelLocale = Prettify<LexDef<typeof describeLabelsLex, 'labelLocale', Registry>>
+64
lexicons/com/germnetwork/declaration.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.germnetwork.declaration", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "description": "A declaration of a Germ Network account", 8 + "key": "literal:self", 9 + "record": { 10 + "type": "object", 11 + "required": ["version", "currentKey"], 12 + "properties": { 13 + "version": { 14 + "type": "string", 15 + "description": "Semver version number, without pre-release or build information, for the format of opaque content", 16 + "minLength": 5, 17 + "maxLength": 14 18 + }, 19 + "currentKey": { 20 + "type": "bytes", 21 + "description": "Opaque value, an ed25519 public key prefixed with a byte enum" 22 + }, 23 + "messageMe": { 24 + "type": "ref", 25 + "description": "Controls who can message this account", 26 + "ref": "#messageMe" 27 + }, 28 + "keyPackage": { 29 + "type": "bytes", 30 + "description": "Opaque value, contains MLS KeyPackage(s), and other signature data, and is signed by the currentKey" 31 + }, 32 + "continuityProofs": { 33 + "type": "array", 34 + "description": "Array of opaque values to allow for key rolling", 35 + "items": { 36 + "type": "bytes" 37 + }, 38 + "maxLength": 1000 39 + } 40 + } 41 + } 42 + }, 43 + "messageMe": { 44 + "type": "object", 45 + "required": ["showButtonTo", "messageMeUrl"], 46 + "properties": { 47 + "messageMeUrl": { 48 + "type": "string", 49 + "description": "A URL to present to an account that does not have its own com.germnetwork.declaration record, where the app should fill in the fragment component with the DIDs of the two accounts who wish to message each other", 50 + "format": "uri", 51 + "minLength": 1, 52 + "maxLength": 2047 53 + }, 54 + "showButtonTo": { 55 + "type": "string", 56 + "knownValues": ["none", "usersIFollow", "everyone"], 57 + "description": "The policy of who can message the account, this value is included in the keyPackage, but is duplicated here to allow applications to decide if they should show a 'Message on Germ' button to the viewer.", 58 + "minLength": 1, 59 + "maxLength": 100 60 + } 61 + } 62 + } 63 + } 64 + }
+4 -4
package-lock.json
··· 6 6 "": { 7 7 "name": "grain", 8 8 "dependencies": { 9 - "@hatk/hatk": "^0.0.1-alpha.49", 9 + "@hatk/hatk": "^0.0.1-alpha.50", 10 10 "@sveltejs/adapter-node": "^5.5.4", 11 11 "@sveltejs/kit": "^2.55.0", 12 12 "@tanstack/svelte-query": "^6.1.0", ··· 163 163 } 164 164 }, 165 165 "node_modules/@hatk/hatk": { 166 - "version": "0.0.1-alpha.49", 167 - "resolved": "https://registry.npmjs.org/@hatk/hatk/-/hatk-0.0.1-alpha.49.tgz", 168 - "integrity": "sha512-C8iziYPwX5yhPdh8100E2f+6K1qJJc2fJxbqcVnw9O0FaGrAJL8Gm1D+KOy3TVzisSHnMXi1oOowgVPjcuVL3A==", 166 + "version": "0.0.1-alpha.50", 167 + "resolved": "https://registry.npmjs.org/@hatk/hatk/-/hatk-0.0.1-alpha.50.tgz", 168 + "integrity": "sha512-Hja8g5evoaok2zPGBp1YLpvkebksB43KVNRcJvlKvOCgujQCoudkNzzMpqu4J8vRN+IZ7Hm96oX7kN18S7OIwA==", 169 169 "license": "MIT", 170 170 "dependencies": { 171 171 "@bigmoves/lexicon": "^0.2.2",
+1 -1
package.json
··· 12 12 "test:browser": "npx playwright test" 13 13 }, 14 14 "dependencies": { 15 - "@hatk/hatk": "^0.0.1-alpha.49", 15 + "@hatk/hatk": "^0.0.1-alpha.50", 16 16 "@sveltejs/adapter-node": "^5.5.4", 17 17 "@sveltejs/kit": "^2.55.0", 18 18 "@tanstack/svelte-query": "^6.1.0",
+18
seeds/seed.ts
··· 58 58 { rkey: "self" }, 59 59 ); 60 60 61 + // ── Germ Network declarations ── 62 + 63 + // Alice has Germ messaging enabled for users she follows 64 + await createRecord( 65 + alice, 66 + "com.germnetwork.declaration", 67 + { 68 + version: "1.1.0", 69 + currentKey: { $bytes: "Azc2tAgeZWS34YcWN7UDzTaOzg4xdjWmYbf+vB5LB4TC" }, 70 + keyPackage: { $bytes: "AL/f8xbdFZKLpMrQ5M/b7QY+BfTgfaGjahUvM+mYpSgLNfX4GabAK35eZcc82/gH5prfMAdL0zstue5tANLRSQ3/AaUAAQOKYDZhOmLO7A2FjOChb0qd/TWXk92+ReIFfsT5aHZmJAICAAAB/wE5AAEABQABAAMgmpd+v4DDNFjLBgz06YhDT2ASVcmL/YJHtEmj1JKcaBYgxd8SYRHfUmB9zcvZD/5tlncv5JC3C/A8h5F72K80j2Qg35g6S+kU9GcGaH42IBHO9ppO6sN9WM778DvzYsy0VM0AASEDimA2YTpizuwNhYzgoW9Knf01l5PdvkXiBX7E+Wh2ZiQCAAEKAAIABwAFAAEAAwAAAgABAQAAAABpxD7yAAAAAGulcnIAQEClCz3WcbcBX1MQ6UXuqgJpmrthu6BaFOx3Mmig8IV742QPVOrKVFeUGLrbrtAzUqLwIQZd7fhSfvGWwSX1yHkAAEBAiXcc+ULcjO7KOzJi7J//OiNKJpNeJzeX47rD62Zpgg5saL9mt0xH7a0oIYpDC3VjEatEkMpEaqyrLD8DR3bQDgBNYOHLzoWUUbuj6/ycxhAC8RIzuqZnNVVU6tft8fKZLTGOOIRLfF1g9YuMv/6dNH3LKx+EfZq/sTOsZ1Ye9sYO" }, 71 + messageMe: { 72 + showButtonTo: "usersIFollow", 73 + messageMeUrl: "https://landing.ger.mx/newUser", 74 + }, 75 + }, 76 + { rkey: "self" }, 77 + ); 78 + 61 79 // Alice follows Bob and Carol 62 80 await createRecord( 63 81 alice,
+7 -1
server/xrpc/getActorProfile.ts
··· 1 1 import { defineQuery, InvalidRequestError } from "$hatk"; 2 - import type { GrainActorProfile } from "$hatk"; 2 + import type { GrainActorProfile, Declaration } from "$hatk"; 3 3 4 4 export default defineQuery("social.grain.unspecced.getActorProfile", async (ctx) => { 5 5 const { ok, params, isTakendown, lookup, count, blobUrl } = ctx; ··· 24 24 25 25 const [ 26 26 profiles, 27 + germDeclarations, 27 28 galleryCounts, 28 29 followerCounts, 29 30 followsCounts, ··· 31 32 followedByRows, 32 33 ] = await Promise.all([ 33 34 lookup<GrainActorProfile>("social.grain.actor.profile", "did", [actor]), 35 + lookup<Declaration>("com.germnetwork.declaration", "did", [actor]), 34 36 count("social.grain.gallery", "did", [actor]), 35 37 ctx.db 36 38 .query( ··· 69 71 const followedBy = (followedByRows as { uri: string }[])[0]?.uri ?? null; 70 72 71 73 const profile = profiles.get(actor); 74 + const germDecl = germDeclarations.get(actor); 75 + const messageMe = germDecl?.value.messageMe ?? null; 72 76 const galleryCount = galleryCounts.get(actor) || 0; 73 77 const followersCount = followerCounts.get(actor) || 0; 74 78 const followsCount = followsCounts.get(actor) || 0; ··· 84 88 galleryCount, 85 89 followersCount, 86 90 followsCount, 91 + ...(messageMe ? { messageMe } : {}), 87 92 }); 88 93 } 89 94 ··· 98 103 followersCount, 99 104 followsCount, 100 105 createdAt: profile.value.createdAt, 106 + ...(messageMe ? { messageMe } : {}), 101 107 ...(viewer && viewer !== actor && (viewerFollowing || followedBy) 102 108 ? { 103 109 viewer: {
static/germ-logo.png

This is a binary file and will not be displayed.