Coffee journaling on ATProto (alpha) alpha.arabica.social
coffee

feat: improved styling of brews list page #12

closed opened by pdewey.com targeting main from refactor-svelte
Labels

None yet.

assignee

None yet.

Participants 1
AT URI
at://did:plc:hm5f3dnm6jdhrc55qp2npdja/sh.tangled.repo.pull/3mdercwj73l22
+1321 -444
Diff #0
+26 -28
AGENTS.md
··· 11 11 - **Testing:** Standard library testing + [shutter](https://github.com/ptdewey/shutter) for snapshot tests 12 12 - **Logging:** zerolog 13 13 14 + ## Use Go Tooling Effectively 15 + 16 + - To see source files from a dependency, or to answer questions 17 + about a dependency, run `go mod download -json MODULE` and use 18 + the returned `Dir` path to read the files. 19 + 20 + - Use `go doc foo.Bar` or `go doc -all foo` to read documentation 21 + for packages, types, functions, etc. 22 + 23 + - Use `go run .` or `go run ./cmd/foo` instead of `go build` to 24 + run programs, to avoid leaving behind build artifacts. 25 + 14 26 ## Project Structure 15 27 16 28 ``` ··· 143 155 **Location:** `internal/handlers/api_snapshot_test.go` 144 156 145 157 **Covered endpoints:** 158 + 146 159 - Authentication: `/api/me`, `/client-metadata.json` 147 160 - Data fetching: `/api/data`, `/api/feed-json`, `/api/profile-json/{actor}` 148 161 - CRUD operations: Create/Update/Delete for beans, roasters, grinders, brewers, brews 149 162 150 163 **Running snapshot tests:** 164 + 151 165 ```bash 152 166 cd internal/handlers && go test -v -run "Snapshot" 153 167 ``` 154 168 155 169 **Working with snapshots:** 170 + 156 171 ```bash 157 172 # Accept all new/changed snapshots 158 173 shutter accept-all ··· 165 180 ``` 166 181 167 182 **Snapshot patterns used:** 183 + 168 184 - `shutter.ScrubTimestamp()` - Removes timestamp values for deterministic tests 169 185 - `shutter.IgnoreKey("created_at")` - Ignores specific JSON keys 170 186 - `shutter.IgnoreKey("rkey")` - Ignores AT Protocol record keys (TIDs are time-based) ··· 177 193 go build -o arabica cmd/arabica-server/main.go 178 194 ``` 179 195 180 - ## Command-Line Flags 181 - 182 - | Flag | Type | Default | Description | 183 - | --------------- | ------ | ------- | ----------------------------------------------------- | 184 - | `--firehose` | bool | true | [DEPRECATED] Firehose is now the default (ignored) | 185 - | `--known-dids` | string | "" | Path to file with DIDs to backfill (one per line) | 186 - 187 - **Known DIDs File Format:** 188 - - One DID per line (e.g., `did:plc:abc123xyz`) 189 - - Lines starting with `#` are comments 190 - - Empty lines are ignored 191 - - See `known-dids.txt.example` for reference 192 - 193 196 ## Environment Variables 194 197 195 - | Variable | Default | Description | 196 - | --------------------------- | --------------------------------- | ---------------------------------- | 197 - | `PORT` | 18910 | HTTP server port | 198 - | `SERVER_PUBLIC_URL` | - | Public URL for reverse proxy (enables secure cookies when HTTPS) | 199 - | `ARABICA_DB_PATH` | ~/.local/share/arabica/arabica.db | BoltDB path (sessions, registry) | 200 - | `ARABICA_FEED_INDEX_PATH` | ~/.local/share/arabica/feed-index.db | Firehose index BoltDB path | 201 - | `ARABICA_PROFILE_CACHE_TTL` | 1h | Profile cache duration | 202 - | `LOG_LEVEL` | info | debug/info/warn/error | 203 - | `LOG_FORMAT` | console | console/json | 198 + | Variable | Default | Description | 199 + | --------------------------- | ------------------------------------ | ---------------------------------------------------------------- | 200 + | `PORT` | 18910 | HTTP server port | 201 + | `SERVER_PUBLIC_URL` | - | Public URL for reverse proxy (enables secure cookies when HTTPS) | 202 + | `ARABICA_DB_PATH` | ~/.local/share/arabica/arabica.db | BoltDB path (sessions, registry) | 203 + | `ARABICA_FEED_INDEX_PATH` | ~/.local/share/arabica/feed-index.db | Firehose index BoltDB path | 204 + | `ARABICA_PROFILE_CACHE_TTL` | 1h | Profile cache duration | 205 + | `LOG_LEVEL` | info | debug/info/warn/error | 206 + | `LOG_FORMAT` | console | console/json | 204 207 205 208 ## Code Patterns 206 209 ··· 353 356 354 357 ## Known Issues / TODOs 355 358 356 - Key areas: 357 - 358 - - Context should flow through methods (some fixed, verify all paths) 359 - - Cache race conditions need copy-on-write pattern 360 - - Missing CID validation on record updates (AT Protocol best practice) 361 - - Rate limiting for PDS calls not implemented 359 + See @BACKLOG.md
+16 -2
BACKLOG.md
··· 20 20 - Manage + brews list together probably makes sense 21 21 22 22 - IMPORTANT: If this platform gains any traction, we will need some form of content moderation 23 - - Due to the nature of arabica, this will only really be text based (text and hyperlinks) 23 + - Due to the nature of arabica, this will only really need to be text based (text and hyperlinks) 24 24 - Malicious link scanning may be reasonable, not sure about deeper text analysis 25 25 - Need to do more research into security 26 26 - Need admin tooling at the app level that will allow deleting records (may not be possible), 27 27 removing from appview, blacklisting users (and maybe IPs?), possibly more 28 28 - Having accounts with admin rights may be an approach to this (configured with flags at startup time?) 29 29 @arabica.social, @pdewey.com, maybe others? (need trusted users in other time zones probably) 30 + - Add on some piece to the TOS that mentions I reserve the right to de-list content from the platform 31 + - Continue limiting firehose posts to users who have been previously authenticated (keep a permanent record of "trusted" users) 32 + - By logging in users agree to TOS -- can create records to be displayed on the appview ("signal" records) 33 + Attestation signature from appview (or pds -- use key from pds) was source of record being created 34 + - This is a pretty important consideration going forward, lots to consider 30 35 31 36 ## Features 32 37 ··· 57 62 - Might be able to just save to the db when backfilling a profile's records 58 63 - NOTE: requires research into existing solustions (whatever tangled does is probably good) 59 64 65 + - Opengraph metadata in brew entry page, to allow rich embeds in bluesky 66 + - All pages should have opengraph metadat, but view brew, profile, and home/feed are probably the most important 67 + 68 + - Maybe move water amount below pours in form, sum pours if they are entered first. 69 + - Would need to not override if water amount is entered after pours 70 + (maybe update after leaving pour input?). 71 + 60 72 ## Fixes 61 73 62 74 - Migrate terms page text. Add links to about at top of non-authed home page ··· 69 81 70 82 - Show "view" button on brews in profile page (same as on brews list page) 71 83 72 - - Fix nix build, nix run, to build frontend as well 84 + - The "back" button behaves kind of strangely 85 + - Goes back to brews list after clicking on view bean in feed, 86 + takes to profile for other users' brews.
-326
CLAUDE.md
··· 1 - # Arabica - Project Context for AI Agents 2 - 3 - Coffee brew tracking application using AT Protocol for decentralized storage. 4 - 5 - ## Tech Stack 6 - 7 - - **Language:** Go 1.21+ 8 - - **HTTP:** stdlib `net/http` with Go 1.22 routing 9 - - **Storage:** AT Protocol PDS (user data), BoltDB (sessions/feed registry) 10 - - **Frontend:** Svelte SPA with client-side routing 11 - - **Legacy:** HTMX partials still used for some dynamic content (being phased out) 12 - - **Logging:** zerolog 13 - 14 - ## Project Structure 15 - 16 - ``` 17 - cmd/arabica-server/main.go # Application entry point 18 - internal/ 19 - atproto/ # AT Protocol integration 20 - client.go # Authenticated PDS client (XRPC calls) 21 - oauth.go # OAuth flow with PKCE/DPOP 22 - store.go # database.Store implementation using PDS 23 - cache.go # Per-session in-memory cache 24 - records.go # Model <-> ATProto record conversion 25 - resolver.go # AT-URI parsing and reference resolution 26 - public_client.go # Unauthenticated public API access 27 - nsid.go # Collection NSIDs and AT-URI builders 28 - handlers/ 29 - handlers.go # HTTP handlers (API endpoints + HTMX partials) 30 - auth.go # OAuth login/logout/callback 31 - bff/ 32 - render.go # Legacy template rendering (HTMX partials only) 33 - helpers.go # View helpers (formatting, etc.) 34 - database/ 35 - store.go # Store interface definition 36 - boltstore/ # BoltDB implementation for sessions 37 - feed/ 38 - service.go # Community feed aggregation 39 - registry.go # User registration for feed 40 - models/ 41 - models.go # Domain models and request types 42 - middleware/ 43 - logging.go # Request logging middleware 44 - routing/ 45 - routing.go # Router setup and middleware chain 46 - frontend/ # Svelte SPA source code 47 - src/ 48 - routes/ # Page components 49 - components/ # Reusable components 50 - stores/ # Svelte stores (auth, cache) 51 - lib/ # Utilities (router, API client) 52 - public/ # Built SPA assets 53 - lexicons/ # AT Protocol lexicon definitions (JSON) 54 - templates/partials/ # Legacy HTMX partial templates (being phased out) 55 - static/ # Static assets (CSS, icons, service worker) 56 - app/ # Built Svelte SPA 57 - ``` 58 - 59 - ## Key Concepts 60 - 61 - ### AT Protocol Integration 62 - 63 - User data stored in their Personal Data Server (PDS), not locally. The app: 64 - 65 - 1. Authenticates via OAuth (indigo SDK handles PKCE/DPOP) 66 - 2. Gets access token scoped to user's DID 67 - 3. Performs CRUD via XRPC calls to user's PDS 68 - 69 - **Collections (NSIDs):** 70 - 71 - - `social.arabica.alpha.bean` - Coffee beans 72 - - `social.arabica.alpha.roaster` - Roasters 73 - - `social.arabica.alpha.grinder` - Grinders 74 - - `social.arabica.alpha.brewer` - Brewing devices 75 - - `social.arabica.alpha.brew` - Brew sessions (references bean, grinder, brewer) 76 - 77 - **Record keys:** TID format (timestamp-based identifiers) 78 - 79 - **References:** Records reference each other via AT-URIs (`at://did/collection/rkey`) 80 - 81 - ### Store Interface 82 - 83 - `internal/database/store.go` defines the `Store` interface. Two implementations: 84 - 85 - - `AtprotoStore` - Production, stores in user's PDS 86 - - BoltDB stores only sessions and feed registry (not user data) 87 - 88 - All Store methods take `context.Context` as first parameter. 89 - 90 - ### Request Flow 91 - 92 - 1. Request hits middleware (logging, auth check) 93 - 2. Auth middleware extracts DID + session ID from cookies 94 - 3. For SPA routes: Serve index.html (client-side routing) 95 - 4. For API routes: Handler creates `AtprotoStore` scoped to user 96 - 5. Store methods make XRPC calls to user's PDS 97 - 6. Results returned as JSON (for SPA) or HTML fragments (legacy HTMX partials) 98 - 99 - ### Caching 100 - 101 - `SessionCache` caches user data in memory (5-minute TTL): 102 - 103 - - Avoids repeated PDS calls for same data 104 - - Invalidated on writes 105 - - Background cleanup removes expired entries 106 - 107 - ### Backfill Strategy 108 - 109 - User records are backfilled from their PDS once per DID: 110 - 111 - - **On startup**: Backfills registered users + known-dids file 112 - - **On first login**: Backfills the user's historical records 113 - - **Deduplication**: Tracks backfilled DIDs in `BucketBackfilled` to prevent redundant fetches 114 - - **Idempotent**: Safe to call multiple times (checks backfill status first) 115 - 116 - This prevents excessive PDS requests while ensuring new users' historical data is indexed. 117 - 118 - ## Common Tasks 119 - 120 - ### Run Development Server 121 - 122 - ```bash 123 - # Run server (uses firehose mode by default) 124 - go run cmd/arabica-server/main.go 125 - 126 - # Backfill known DIDs on startup 127 - go run cmd/arabica-server/main.go --known-dids known-dids.txt 128 - 129 - # Using nix 130 - nix run 131 - ``` 132 - 133 - ### Run Tests 134 - 135 - ```bash 136 - go test ./... 137 - ``` 138 - 139 - ### Build 140 - 141 - ```bash 142 - go build -o arabica cmd/arabica-server/main.go 143 - ``` 144 - 145 - ## Command-Line Flags 146 - 147 - | Flag | Type | Default | Description | 148 - | --------------- | ------ | ------- | ----------------------------------------------------- | 149 - | `--firehose` | bool | true | [DEPRECATED] Firehose is now the default (ignored) | 150 - | `--known-dids` | string | "" | Path to file with DIDs to backfill (one per line) | 151 - 152 - **Known DIDs File Format:** 153 - - One DID per line (e.g., `did:plc:abc123xyz`) 154 - - Lines starting with `#` are comments 155 - - Empty lines are ignored 156 - - See `known-dids.txt.example` for reference 157 - 158 - ## Environment Variables 159 - 160 - | Variable | Default | Description | 161 - | --------------------------- | --------------------------------- | ---------------------------------- | 162 - | `PORT` | 18910 | HTTP server port | 163 - | `SERVER_PUBLIC_URL` | - | Public URL for reverse proxy (enables secure cookies when HTTPS) | 164 - | `ARABICA_DB_PATH` | ~/.local/share/arabica/arabica.db | BoltDB path (sessions, registry) | 165 - | `ARABICA_FEED_INDEX_PATH` | ~/.local/share/arabica/feed-index.db | Firehose index BoltDB path | 166 - | `ARABICA_PROFILE_CACHE_TTL` | 1h | Profile cache duration | 167 - | `LOG_LEVEL` | info | debug/info/warn/error | 168 - | `LOG_FORMAT` | console | console/json | 169 - 170 - ## Code Patterns 171 - 172 - ### Creating a Store 173 - 174 - ```go 175 - // In handlers, store is created per-request 176 - store, authenticated := h.getAtprotoStore(r) 177 - if !authenticated { 178 - http.Error(w, "Authentication required", http.StatusUnauthorized) 179 - return 180 - } 181 - 182 - // Use store with request context 183 - brews, err := store.ListBrews(r.Context(), userID) 184 - ``` 185 - 186 - ### Record Conversion 187 - 188 - ```go 189 - // Model -> ATProto record 190 - record, err := BrewToRecord(brew, beanURI, grinderURI, brewerURI) 191 - 192 - // ATProto record -> Model 193 - brew, err := RecordToBrew(record, atURI) 194 - ``` 195 - 196 - ### AT-URI Handling 197 - 198 - ```go 199 - // Build AT-URI 200 - uri := BuildATURI(did, NSIDBean, rkey) // at://did:plc:xxx/social.arabica.alpha.bean/abc 201 - 202 - // Parse AT-URI 203 - components, err := ResolveATURI(uri) 204 - // components.DID, components.Collection, components.RKey 205 - ``` 206 - 207 - ## Future Vision: Social Features 208 - 209 - The app currently has a basic community feed. Future plans expand social interactions leveraging AT Protocol's decentralized nature. 210 - 211 - ### Planned Lexicons 212 - 213 - ``` 214 - social.arabica.alpha.like - Like a brew (references brew AT-URI) 215 - social.arabica.alpha.comment - Comment on a brew 216 - social.arabica.alpha.follow - Follow another user 217 - social.arabica.alpha.share - Re-share a brew to your feed 218 - ``` 219 - 220 - ### Like Record (Planned) 221 - 222 - ```json 223 - { 224 - "lexicon": 1, 225 - "id": "social.arabica.alpha.like", 226 - "defs": { 227 - "main": { 228 - "type": "record", 229 - "key": "tid", 230 - "record": { 231 - "type": "object", 232 - "required": ["subject", "createdAt"], 233 - "properties": { 234 - "subject": { 235 - "type": "ref", 236 - "ref": "com.atproto.repo.strongRef", 237 - "description": "The brew being liked" 238 - }, 239 - "createdAt": { "type": "string", "format": "datetime" } 240 - } 241 - } 242 - } 243 - } 244 - } 245 - ``` 246 - 247 - ### Comment Record (Planned) 248 - 249 - ```json 250 - { 251 - "lexicon": 1, 252 - "id": "social.arabica.alpha.comment", 253 - "defs": { 254 - "main": { 255 - "type": "record", 256 - "key": "tid", 257 - "record": { 258 - "type": "object", 259 - "required": ["subject", "text", "createdAt"], 260 - "properties": { 261 - "subject": { 262 - "type": "ref", 263 - "ref": "com.atproto.repo.strongRef", 264 - "description": "The brew being commented on" 265 - }, 266 - "text": { 267 - "type": "string", 268 - "maxLength": 1000, 269 - "maxGraphemes": 300 270 - }, 271 - "createdAt": { "type": "string", "format": "datetime" } 272 - } 273 - } 274 - } 275 - } 276 - } 277 - ``` 278 - 279 - ### Implementation Approach 280 - 281 - **Cross-user interactions:** 282 - 283 - - Likes/comments stored in the actor's PDS (not the brew owner's) 284 - - Use `public_client.go` to read other users' brews 285 - - Aggregate likes/comments via relay/firehose or direct PDS queries 286 - 287 - **Feed aggregation:** 288 - 289 - - Current: Poll registered users' PDS for brews 290 - - Future: Subscribe to firehose for real-time updates 291 - - Index social interactions in local DB for fast queries 292 - 293 - **UI patterns:** 294 - 295 - - Like button on brew cards in feed 296 - - Comment thread below brew detail view 297 - - Share button to re-post with optional note 298 - - Notification system for interactions on your brews 299 - 300 - ### Key Design Decisions 301 - 302 - 1. **Strong references** - Likes/comments use `com.atproto.repo.strongRef` (URI + CID) to ensure the referenced brew hasn't changed 303 - 2. **Actor-owned data** - Your likes live in your PDS, not the brew owner's 304 - 3. **Public by default** - Social interactions are public records, readable by anyone 305 - 4. **Portable identity** - Users can switch PDS and keep their social graph 306 - 307 - ## Deployment Notes 308 - 309 - ### CSS Cache Busting 310 - 311 - When making CSS/style changes, bump the version query parameter in `templates/layout.tmpl`: 312 - 313 - ```html 314 - <link rel="stylesheet" href="/static/css/output.css?v=0.1.3" /> 315 - ``` 316 - 317 - Cloudflare caches static assets, so incrementing the version ensures users get the updated styles. 318 - 319 - ## Known Issues / TODOs 320 - 321 - Key areas: 322 - 323 - - Context should flow through methods (some fixed, verify all paths) 324 - - Cache race conditions need copy-on-write pattern 325 - - Missing CID validation on record updates (AT Protocol best practice) 326 - - Rate limiting for PDS calls not implemented
+1 -22
README.md
··· 2 2 3 3 Coffee brew tracking application build on ATProto 4 4 5 - Development is on GitHub, and is mirrored to Tangled: 5 + Development is primarily happening on Tangled, and is mirrored to GitHub: 6 6 7 7 - [Tangled](https://tangled.org/arabica.social/arabica) 8 8 - [GitHub](https://github.com/arabica-social/arabica) 9 9 10 - GitHub is currently the primary repo, but that may change in the future. 11 - 12 10 ## Features 13 11 14 12 - Track coffee brews with detailed parameters 15 13 - Store data in your AT Protocol Personal Data Server 16 14 - Community feed of recent brews from registered users (polling or real-time firehose) 17 15 - Manage beans, roasters, grinders, and brewers 18 - - Export brew data as JSON 19 16 - Mobile-friendly PWA design 20 17 21 - ## Tech Stack 22 - 23 - - Backend: Go with stdlib HTTP router 24 - - Storage: AT Protocol Personal Data Servers + BoltDB for local cache 25 - - Templates: html/template 26 - - Frontend: HTMX + Alpine.js + Tailwind CSS 27 - 28 18 ## Quick Start 29 19 30 20 ```bash ··· 70 60 - `LOG_LEVEL` - Logging level: debug, info, warn, error (default: info) 71 61 - `LOG_FORMAT` - Log format: console, json (default: console) 72 62 73 - ## Architecture 74 - 75 - Data is stored in AT Protocol records on users' Personal Data Servers. The application uses OAuth to authenticate with the PDS and performs all CRUD operations via the AT Protocol API. 76 - 77 - Local BoltDB stores: 78 - 79 - - OAuth session data 80 - - Feed registry (list of DIDs for community feed) 81 - 82 - See docs/ for detailed documentation. 83 - 84 63 ## Development 85 64 86 65 ```bash
+1050
docs/likes-follows-comments-plan.md
··· 1 + # Arabica Social Features Plan: Likes, Follows, and Comments 2 + 3 + **Version:** 1.0 4 + **Date:** January 25, 2026 5 + **Status:** Planning 6 + 7 + --- 8 + 9 + TODO: 10 + 11 + - This is not going to be the current state, I don't love the plan claude made 12 + - Likes will probably be their own lexicon (maybe with a lens to bsky likes? -- probably not) 13 + - Comments tbd, I would like to avoid forcing users onto bsky for social features though 14 + - Follows, allow importing social graph from bsky (might be able to use a sort of statndardized lexicon here?) 15 + - Likely creating a custom lexicon that is structurally similar/the same as bsky (maybe standard.site sub/pub lex if that would work?) 16 + 17 + --- 18 + 19 + ## Executive Summary 20 + 21 + This document outlines the implementation plan for adding social features to Arabica: likes, follows, and comments. The plan leverages AT Protocol's decentralized architecture while evaluating strategic reuse of Bluesky's existing social lexicons versus creating Arabica-specific ones. 22 + 23 + ## Table of Contents 24 + 25 + 1. [Goals & Non-Goals](#goals--non-goals) 26 + 2. [Architecture Overview](#architecture-overview) 27 + 3. [Lexicon Design Decisions](#lexicon-design-decisions) 28 + 4. [Implementation Phases](#implementation-phases) 29 + 5. [Technical Details](#technical-details) 30 + 6. [Bluesky Integration Strategies](#bluesky-integration-strategies) 31 + 7. [Data Flow & Storage](#data-flow--storage) 32 + 8. [UI/UX Considerations](#uiux-considerations) 33 + 9. [Migration & Rollout](#migration--rollout) 34 + 10. [Future Enhancements](#future-enhancements) 35 + 36 + --- 37 + 38 + ## Goals & Non-Goals 39 + 40 + ### Goals 41 + 42 + - **Enable likes** on brews, beans, roasters, grinders, and brewers 43 + - **Support follows** to create personalized feeds of coffee enthusiasts 44 + - **Add comments** to enable discussions around brews and equipment 45 + - **Maintain decentralization**: Social interactions stored in users' own PDS 46 + - **Leverage existing infrastructure**: Use Bluesky's lexicons where beneficial 47 + - **Preserve portability**: Users can take their data anywhere 48 + - **Enable discoverability**: Surface popular content and active users 49 + 50 + ### Non-Goals 51 + 52 + - Building a full social network (messaging, DMs, notifications beyond basic) 53 + - Implementing moderation tools (initial phase) 54 + - Creating a mobile app (web-first approach) 55 + - Supporting multimedia beyond existing image support 56 + 57 + --- 58 + 59 + ## Architecture Overview 60 + 61 + ### Current State 62 + 63 + ``` 64 + User's PDS 65 + ├── social.arabica.alpha.bean (coffee beans) 66 + ├── social.arabica.alpha.roaster (roasters) 67 + ├── social.arabica.alpha.grinder (grinders) 68 + ├── social.arabica.alpha.brewer (brewing devices) 69 + └── social.arabica.alpha.brew (brew sessions) 70 + 71 + Arabica Server 72 + ├── Firehose Listener (crawls network for brew data) 73 + ├── Feed Index (BoltDB - aggregated feed) 74 + ├── Session Store (BoltDB - sessions/registry) 75 + └── Profile Cache (in-memory, 1hr TTL) 76 + ``` 77 + 78 + ### Proposed State 79 + 80 + ``` 81 + User's PDS 82 + ├── Arabica Records 83 + │ ├── social.arabica.alpha.bean 84 + │ ├── social.arabica.alpha.roaster 85 + │ ├── social.arabica.alpha.grinder 86 + │ ├── social.arabica.alpha.brewer 87 + │ └── social.arabica.alpha.brew 88 + 89 + ├── Social Interactions (Option A: Arabica-specific) 90 + │ ├── social.arabica.alpha.like 91 + │ ├── social.arabica.alpha.follow 92 + │ └── social.arabica.alpha.comment 93 + 94 + └── Social Interactions (Option B: Bluesky lexicons) 95 + ├── app.bsky.feed.like (reuse for likes) 96 + ├── app.bsky.graph.follow (reuse for follows) 97 + └── social.arabica.alpha.comment (custom for comments) 98 + 99 + Arabica Server 100 + ├── Firehose Listener (+ like/follow/comment indexing) 101 + ├── Social Index (BoltDB - likes, follows, comments) 102 + ├── Feed Index (enhanced with social signals) 103 + ├── Session Store 104 + └── Profile Cache 105 + ``` 106 + 107 + --- 108 + 109 + ## Lexicon Design Decisions 110 + 111 + ### Decision Matrix 112 + 113 + | Feature | Custom Lexicon | Bluesky Lexicon | Recommendation | 114 + | ------------ | ------------------------------ | ------------------------------ | ----------------- | 115 + | **Likes** | `social.arabica.alpha.like` | `app.bsky.feed.like` | **Use Bluesky** | 116 + | **Follows** | `social.arabica.alpha.follow` | `app.bsky.graph.follow` | **Use Bluesky** | 117 + | **Comments** | `social.arabica.alpha.comment` | `app.bsky.feed.post` (replies) | **Create Custom** | 118 + 119 + ### Rationale 120 + 121 + #### ✅ Use `app.bsky.feed.like` for Likes 122 + 123 + **Pros:** 124 + 125 + - Simple, well-tested schema (just subject + timestamp) 126 + - Enables cross-app discoverability (Bluesky users can see popular coffee content) 127 + - No need to maintain our own lexicon 128 + - Future compatibility with Bluesky social graph features 129 + - Users' existing Bluesky likes are already in their PDS 130 + 131 + **Cons:** 132 + 133 + - Couples us to Bluesky's schema evolution 134 + - Mixing Arabica and Bluesky content in like feeds 135 + 136 + **Schema:** 137 + 138 + ```json 139 + { 140 + "lexicon": 1, 141 + "id": "app.bsky.feed.like", 142 + "defs": { 143 + "main": { 144 + "type": "record", 145 + "key": "tid", 146 + "record": { 147 + "type": "object", 148 + "required": ["subject", "createdAt"], 149 + "properties": { 150 + "subject": { 151 + "type": "ref", 152 + "ref": "com.atproto.repo.strongRef", 153 + "description": "AT-URI + CID of the liked record" 154 + }, 155 + "createdAt": { 156 + "type": "string", 157 + "format": "datetime" 158 + } 159 + } 160 + } 161 + } 162 + } 163 + } 164 + ``` 165 + 166 + **Example Record:** 167 + 168 + ```json 169 + { 170 + "$type": "app.bsky.feed.like", 171 + "subject": { 172 + "uri": "at://did:plc:user123/social.arabica.alpha.brew/abc123", 173 + "cid": "bafyreibjifzpqj6o6wcq3hejh7y4z4z2vmiklkvykc57tw3pcbx3kxifpm" 174 + }, 175 + "createdAt": "2026-01-25T12:30:00.000Z" 176 + } 177 + ``` 178 + 179 + #### ✅ Use `app.bsky.graph.follow` for Follows 180 + 181 + **Pros:** 182 + 183 + - Standard social graph representation 184 + - Interoperability: Arabica follows visible in Bluesky social graph 185 + - Enables "import follows from Bluesky" (see below) 186 + - Could power recommendations ("Users who brew X also follow Y") 187 + - Simplifies social graph queries 188 + 189 + **Cons:** 190 + 191 + - Follow graph will mix Arabica and Bluesky users 192 + - Need to filter by context in queries 193 + 194 + **Schema:** 195 + 196 + ```json 197 + { 198 + "lexicon": 1, 199 + "id": "app.bsky.graph.follow", 200 + "defs": { 201 + "main": { 202 + "type": "record", 203 + "key": "tid", 204 + "record": { 205 + "type": "object", 206 + "required": ["subject", "createdAt"], 207 + "properties": { 208 + "subject": { 209 + "type": "string", 210 + "format": "did", 211 + "description": "DID of the user being followed" 212 + }, 213 + "createdAt": { 214 + "type": "string", 215 + "format": "datetime" 216 + } 217 + } 218 + } 219 + } 220 + } 221 + } 222 + ``` 223 + 224 + **Example Record:** 225 + 226 + ```json 227 + { 228 + "$type": "app.bsky.graph.follow", 229 + "subject": "did:plc:coffee-enthusiast-456", 230 + "createdAt": "2026-01-25T12:30:00.000Z" 231 + } 232 + ``` 233 + 234 + #### ✅ Create `social.arabica.alpha.comment` for Comments 235 + 236 + **Pros:** 237 + 238 + - Coffee-specific comment features (e.g., ratings, tasting notes) 239 + - Can extend with Arabica-specific fields 240 + - Cleaner separation from Bluesky post threads 241 + - No confusion between "replies" and "comments" 242 + 243 + **Cons:** 244 + 245 + - Maintains another lexicon 246 + - Comments won't appear in Bluesky's thread views 247 + - Need to build our own comment threading 248 + 249 + **Proposed Schema:** 250 + 251 + ```json 252 + { 253 + "lexicon": 1, 254 + "id": "social.arabica.alpha.comment", 255 + "defs": { 256 + "main": { 257 + "type": "record", 258 + "key": "tid", 259 + "description": "A comment on a brew or equipment", 260 + "record": { 261 + "type": "object", 262 + "required": ["subject", "text", "createdAt"], 263 + "properties": { 264 + "subject": { 265 + "type": "ref", 266 + "ref": "com.atproto.repo.strongRef", 267 + "description": "The brew/bean/roaster/etc being commented on" 268 + }, 269 + "text": { 270 + "type": "string", 271 + "maxLength": 2000, 272 + "maxGraphemes": 500, 273 + "description": "Comment text" 274 + }, 275 + "parent": { 276 + "type": "ref", 277 + "ref": "com.atproto.repo.strongRef", 278 + "description": "Parent comment for threading (optional)" 279 + }, 280 + "facets": { 281 + "type": "array", 282 + "description": "Mentions, links, hashtags", 283 + "items": { 284 + "type": "ref", 285 + "ref": "app.bsky.richtext.facet" 286 + } 287 + }, 288 + "rating": { 289 + "type": "integer", 290 + "minimum": 1, 291 + "maximum": 10, 292 + "description": "Optional rating (1-10)" 293 + }, 294 + "createdAt": { 295 + "type": "string", 296 + "format": "datetime" 297 + } 298 + } 299 + } 300 + } 301 + } 302 + } 303 + ``` 304 + 305 + **Example Record:** 306 + 307 + ```json 308 + { 309 + "$type": "social.arabica.alpha.comment", 310 + "subject": { 311 + "uri": "at://did:plc:user123/social.arabica.alpha.brew/xyz789", 312 + "cid": "bafyreig2fjxi3rptqdgylg7e5hmjl6mcke7rn2b6cugzlqq3i4zu6rq52q" 313 + }, 314 + "text": "Lovely floral notes! What was your water temp?", 315 + "rating": 8, 316 + "createdAt": "2026-01-25T14:00:00.000Z" 317 + } 318 + ``` 319 + 320 + --- 321 + 322 + ## Implementation Phases 323 + 324 + ### Phase 1: Likes (2-3 weeks) 325 + 326 + **Deliverables:** 327 + 328 + 1. ✅ Lexicon decision: Use `app.bsky.feed.like` 329 + 2. Backend: Index likes from firehose 330 + 3. Backend: Aggregate like counts per record 331 + 4. Backend: API endpoints for liking/unliking 332 + 5. Frontend: Like button UI on brew cards 333 + 6. Frontend: Display like counts 334 + 7. Testing: Snapshot tests for like endpoints 335 + 336 + **Technical Tasks:** 337 + 338 + - Update firehose listener to capture `app.bsky.feed.like` records 339 + - Add `LikesIndex` to BoltDB (keyed by subject AT-URI) 340 + - Implement `GetLikeCount(uri string)` function 341 + - Implement `UserHasLiked(userDID, uri string)` function 342 + - Create/delete like via PDS client 343 + - Frontend: Like button component with optimistic updates 344 + 345 + **Database Schema (BoltDB):** 346 + 347 + ``` 348 + Bucket: Likes 349 + Key: <subject-at-uri> 350 + Value: { 351 + "count": 42, 352 + "recent": ["did:plc:user1", "did:plc:user2", ...] // last 10 likers 353 + } 354 + 355 + Bucket: UserLikes 356 + Key: <user-did>/<subject-at-uri> 357 + Value: <like-record-uri> // for quick "has user liked this?" checks 358 + ``` 359 + 360 + ### Phase 2: Follows (3-4 weeks) 361 + 362 + **Deliverables:** 363 + 364 + 1. ✅ Lexicon decision: Use `app.bsky.graph.follow` 365 + 2. Backend: Index follows from firehose 366 + 3. Backend: Build follower/following graph 367 + 4. Backend: Personalized feed based on follows 368 + 5. Frontend: Follow button on user profiles 369 + 6. Frontend: Followers/Following pages 370 + 7. Feature: Import follows from Bluesky (see below) 371 + 372 + **Technical Tasks:** 373 + 374 + - Update firehose listener to capture `app.bsky.graph.follow` records 375 + - Add `FollowsIndex` to BoltDB 376 + - Implement `GetFollowers(did string)` function 377 + - Implement `GetFollowing(did string)` function 378 + - Implement `UserFollows(followerDID, followedDID string)` function 379 + - Create/delete follow via PDS client 380 + - Frontend: Follow button component 381 + - Frontend: "Following" feed filter 382 + 383 + **Database Schema (BoltDB):** 384 + 385 + ``` 386 + Bucket: Follows 387 + Key: follower:<did> 388 + Value: ["did:plc:followed1", "did:plc:followed2", ...] 389 + 390 + Bucket: Followers 391 + Key: followed:<did> 392 + Value: ["did:plc:follower1", "did:plc:follower2", ...] 393 + 394 + Bucket: FollowCounts 395 + Key: <did> 396 + Value: { 397 + "followers": 120, 398 + "following": 87 399 + } 400 + ``` 401 + 402 + ### Phase 3: Comments (4-5 weeks) 403 + 404 + **Deliverables:** 405 + 406 + 1. ✅ Lexicon: Create `social.arabica.alpha.comment` 407 + 2. Backend: Index comments from firehose 408 + 3. Backend: Comment threading logic 409 + 4. Backend: Comment counts per record 410 + 5. Frontend: Comment display UI 411 + 6. Frontend: Comment creation form 412 + 7. Frontend: Comment threading/replies 413 + 414 + **Technical Tasks:** 415 + 416 + - Define and publish `social.arabica.alpha.comment` lexicon 417 + - Update firehose listener to capture comment records 418 + - Add `CommentsIndex` to BoltDB 419 + - Implement `GetComments(uri string, limit, offset int)` function 420 + - Implement comment threading/tree building 421 + - Create comment via PDS client 422 + - Frontend: Comment list component 423 + - Frontend: Comment form with mentions/facets support 424 + 425 + **Database Schema (BoltDB):** 426 + 427 + ``` 428 + Bucket: Comments 429 + Key: <subject-at-uri>/<timestamp> 430 + Value: { 431 + "author": "did:plc:user1", 432 + "text": "Great brew!", 433 + "parent": "at://...", // null for top-level 434 + "rating": 9, 435 + "createdAt": "2026-01-25T12:00:00Z", 436 + "uri": "at://did:plc:user1/social.arabica.alpha.comment/abc123" 437 + } 438 + 439 + Bucket: CommentCounts 440 + Key: <subject-at-uri> 441 + Value: 15 442 + ``` 443 + 444 + ### Phase 4: Social Feed Enhancements (2-3 weeks) 445 + 446 + **Deliverables:** 447 + 448 + 1. Following-only feed view 449 + 2. Popular brews (by like count) 450 + 3. Trending equipment 451 + 4. Active users widget 452 + 5. Social notifications (basic) 453 + 454 + --- 455 + 456 + ## Technical Details 457 + 458 + ### 1. Firehose Integration 459 + 460 + **Current:** 461 + 462 + - Listens for `social.arabica.alpha.*` records 463 + - Indexes brews, beans, roasters, grinders, brewers 464 + 465 + **Enhanced:** 466 + 467 + ```go 468 + // internal/firehose/listener.go 469 + 470 + func (l *Listener) handleFirehoseEvent(evt *events.RepoCommit) { 471 + for _, op := range evt.Ops { 472 + switch op.Collection { 473 + // Existing collections 474 + case atproto.NSIDBrew, atproto.NSIDBean, atproto.NSIDRoaster, 475 + atproto.NSIDGrinder, atproto.NSIDBrewer: 476 + l.handleArabicaRecord(op) 477 + 478 + // Social interactions 479 + case "app.bsky.feed.like": 480 + l.handleLike(op) 481 + case "app.bsky.graph.follow": 482 + l.handleFollow(op) 483 + case atproto.NSIDComment: // social.arabica.alpha.comment 484 + l.handleComment(op) 485 + } 486 + } 487 + } 488 + 489 + func (l *Listener) handleLike(op *events.RepoOp) error { 490 + // Parse like record 491 + var like atproto.Like 492 + if err := json.Unmarshal(op.Record, &like); err != nil { 493 + return err 494 + } 495 + 496 + // Filter: only index likes on Arabica content 497 + if !strings.HasPrefix(like.Subject.URI, "at://") { 498 + return nil 499 + } 500 + components := atproto.ParseATURI(like.Subject.URI) 501 + if !strings.HasPrefix(components.Collection, "social.arabica.alpha.") { 502 + return nil // Skip non-Arabica likes 503 + } 504 + 505 + // Index the like 506 + return l.socialIndex.IndexLike(op.Author, &like) 507 + } 508 + ``` 509 + 510 + ### 2. API Endpoints 511 + 512 + **New endpoints:** 513 + 514 + ``` 515 + POST /api/likes # Create a like 516 + DELETE /api/likes # Unlike 517 + GET /api/likes?uri=<record-uri> # Get like count & likers 518 + 519 + POST /api/follows # Follow a user 520 + DELETE /api/follows # Unfollow 521 + GET /api/followers?did=<did> # Get followers 522 + GET /api/following?did=<did> # Get following list 523 + POST /api/import-follows # Import from Bluesky 524 + 525 + POST /api/comments # Create a comment 526 + GET /api/comments?uri=<uri> # Get comments for a record 527 + ``` 528 + 529 + **Example: Like endpoint** 530 + 531 + ```go 532 + // internal/handlers/likes.go 533 + 534 + type LikeRequest struct { 535 + SubjectURI string `json:"uri"` 536 + SubjectCID string `json:"cid"` 537 + } 538 + 539 + func (h *Handlers) CreateLike(w http.ResponseWriter, r *http.Request) { 540 + store, authenticated := h.getAtprotoStore(r) 541 + if !authenticated { 542 + http.Error(w, "Authentication required", http.StatusUnauthorized) 543 + return 544 + } 545 + 546 + var req LikeRequest 547 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 548 + http.Error(w, "Invalid request", http.StatusBadRequest) 549 + return 550 + } 551 + 552 + // Create like record in user's PDS 553 + like := &atproto.Like{ 554 + Type: "app.bsky.feed.like", 555 + Subject: &atproto.StrongRef{ 556 + URI: req.SubjectURI, 557 + CID: req.SubjectCID, 558 + }, 559 + CreatedAt: time.Now().Format(time.RFC3339), 560 + } 561 + 562 + uri, err := store.CreateLike(r.Context(), like) 563 + if err != nil { 564 + http.Error(w, "Failed to create like", http.StatusInternalServerError) 565 + return 566 + } 567 + 568 + json.NewEncoder(w).Encode(map[string]string{ 569 + "uri": uri, 570 + }) 571 + } 572 + ``` 573 + 574 + ### 3. Store Interface Extensions 575 + 576 + ```go 577 + // internal/database/store.go 578 + 579 + type Store interface { 580 + // Existing methods... 581 + 582 + // Likes 583 + CreateLike(ctx context.Context, like *atproto.Like) (string, error) 584 + DeleteLike(ctx context.Context, likeURI string) error 585 + GetLikeCount(ctx context.Context, subjectURI string) (int, error) 586 + GetLikers(ctx context.Context, subjectURI string, limit int) ([]*Profile, error) 587 + UserHasLiked(ctx context.Context, userDID, subjectURI string) (bool, error) 588 + 589 + // Follows 590 + CreateFollow(ctx context.Context, follow *atproto.Follow) (string, error) 591 + DeleteFollow(ctx context.Context, followURI string) error 592 + GetFollowers(ctx context.Context, did string, limit, offset int) ([]*Profile, error) 593 + GetFollowing(ctx context.Context, did string, limit, offset int) ([]*Profile, error) 594 + UserFollows(ctx context.Context, followerDID, followedDID string) (bool, error) 595 + 596 + // Comments 597 + CreateComment(ctx context.Context, comment *atproto.Comment) (string, error) 598 + GetComments(ctx context.Context, subjectURI string, limit, offset int) ([]*Comment, error) 599 + GetCommentCount(ctx context.Context, subjectURI string) (int, error) 600 + } 601 + ``` 602 + 603 + --- 604 + 605 + ## Bluesky Integration Strategies 606 + 607 + ### Import Follows from Bluesky 608 + 609 + **User Story:** 610 + "As a coffee enthusiast on Bluesky, I want to import my Bluesky follows into Arabica so I can follow coffee friends without re-discovering them." 611 + 612 + **Implementation:** 613 + 614 + 1. **Fetch Bluesky Follows** 615 + - Use `app.bsky.graph.getFollows` API 616 + - Query user's PDS: `GET /xrpc/app.bsky.graph.getFollows?actor={userDID}` 617 + - Paginate through results (cursor-based) 618 + 619 + 2. **Filter for Arabica Users** 620 + - Check if followed user has Arabica records 621 + - Query: `listRecords` for `social.arabica.alpha.brew` in their PDS 622 + - Cache results to avoid repeated lookups 623 + 624 + 3. **Create Follow Records** 625 + - For each Arabica user in Bluesky follows, create `app.bsky.graph.follow` in user's PDS 626 + - Skip if already following 627 + 628 + **API Endpoint:** 629 + 630 + ```go 631 + POST /api/import-follows 632 + 633 + Request: 634 + { 635 + "source": "bluesky", 636 + "filter": "arabica-users-only" // or "all" 637 + } 638 + 639 + Response: 640 + { 641 + "imported": 42, 642 + "skipped": 8, 643 + "failed": 1, 644 + "details": [ 645 + {"did": "did:plc:user1", "handle": "@coffee-nerd.bsky.social", "status": "imported"}, 646 + {"did": "did:plc:user2", "handle": "@bean-expert.bsky.social", "status": "already-following"} 647 + ] 648 + } 649 + ``` 650 + 651 + **Implementation:** 652 + 653 + ```go 654 + func (h *Handlers) ImportFollows(w http.ResponseWriter, r *http.Request) { 655 + store, authenticated := h.getAtprotoStore(r) 656 + if !authenticated { 657 + http.Error(w, "Authentication required", http.StatusUnauthorized) 658 + return 659 + } 660 + 661 + userDID := h.getUserDID(r) 662 + 663 + // 1. Fetch Bluesky follows 664 + follows, err := h.fetchBlueskyFollows(r.Context(), userDID) 665 + if err != nil { 666 + http.Error(w, "Failed to fetch follows", http.StatusInternalServerError) 667 + return 668 + } 669 + 670 + // 2. Filter for Arabica users 671 + arabicaUsers := []string{} 672 + for _, follow := range follows { 673 + hasArabicaContent, err := h.hasArabicaRecords(r.Context(), follow.DID) 674 + if err != nil { 675 + log.Warn().Err(err).Str("did", follow.DID).Msg("Failed to check Arabica records") 676 + continue 677 + } 678 + if hasArabicaContent { 679 + arabicaUsers = append(arabicaUsers, follow.DID) 680 + } 681 + } 682 + 683 + // 3. Create follow records 684 + imported := 0 685 + for _, targetDID := range arabicaUsers { 686 + // Check if already following 687 + alreadyFollows, _ := store.UserFollows(r.Context(), userDID, targetDID) 688 + if alreadyFollows { 689 + continue 690 + } 691 + 692 + follow := &atproto.Follow{ 693 + Type: "app.bsky.graph.follow", 694 + Subject: targetDID, 695 + CreatedAt: time.Now().Format(time.RFC3339), 696 + } 697 + _, err := store.CreateFollow(r.Context(), follow) 698 + if err == nil { 699 + imported++ 700 + } 701 + } 702 + 703 + json.NewEncoder(w).Encode(map[string]interface{}{ 704 + "imported": imported, 705 + "total_follows": len(follows), 706 + "arabica_users": len(arabicaUsers), 707 + }) 708 + } 709 + 710 + func (h *Handlers) hasArabicaRecords(ctx context.Context, did string) (bool, error) { 711 + // Use public client to check for any Arabica records 712 + client := atproto.NewPublicClient() 713 + records, err := client.ListRecords(ctx, did, atproto.NSIDBrew, 1) 714 + if err != nil { 715 + return false, err 716 + } 717 + return len(records) > 0, nil 718 + } 719 + ``` 720 + 721 + **Challenges:** 722 + 723 + - **Rate limiting**: Bluesky API has rate limits; may need to batch/queue imports 724 + - **Stale data**: Follows may be out of sync if user unfollows on Bluesky 725 + - **Performance**: Checking each DID for Arabica content is slow 726 + - **Solution**: Maintain a "known Arabica users" index from firehose 727 + 728 + **Enhancement:** Two-way sync 729 + 730 + - Export Arabica follows → Bluesky follows (optional) 731 + - Periodic sync job to keep in sync 732 + 733 + --- 734 + 735 + ## Data Flow & Storage 736 + 737 + ### Like Flow 738 + 739 + ``` 740 + User clicks "Like" on a brew 741 + 742 + Frontend sends POST /api/likes 743 + 744 + Backend creates app.bsky.feed.like record in user's PDS 745 + 746 + PDS broadcasts record to Relay via firehose 747 + 748 + Arabica firehose listener receives event 749 + 750 + SocialIndex updates like count for subject URI 751 + 752 + Cache invalidated (optional) 753 + 754 + Feed refreshes with new like count 755 + ``` 756 + 757 + ### Follow Flow 758 + 759 + ``` 760 + User clicks "Follow" on profile 761 + 762 + Frontend sends POST /api/follows 763 + 764 + Backend creates app.bsky.graph.follow record in user's PDS 765 + 766 + PDS broadcasts to firehose 767 + 768 + Arabica listener updates FollowsIndex 769 + 770 + User's feed now includes followed user's brews 771 + ``` 772 + 773 + ### Comment Flow 774 + 775 + ``` 776 + User submits comment on brew 777 + 778 + Frontend sends POST /api/comments 779 + 780 + Backend creates social.arabica.alpha.comment in user's PDS 781 + 782 + Firehose broadcasts event 783 + 784 + Arabica listener indexes comment 785 + 786 + Comment appears on brew detail page 787 + ``` 788 + 789 + --- 790 + 791 + ## UI/UX Considerations 792 + 793 + ### Like Button 794 + 795 + **States:** 796 + 797 + - Not liked: Gray heart outline 798 + - Liked: Red filled heart 799 + - Loading: Gray heart with spinner 800 + 801 + **Display:** 802 + 803 + - Show like count next to heart 804 + - On hover: Show "X people liked this" 805 + - Click: Optimistic update (instant UI change, API call in background) 806 + 807 + **Location:** 808 + 809 + - Brew cards in feed 810 + - Brew detail page 811 + - Bean/Roaster/Grinder/Brewer detail pages 812 + 813 + ### Follow Button 814 + 815 + **States:** 816 + 817 + - Not following: "Follow" button (blue) 818 + - Following: "Following" button (gray, checkmark) 819 + - Hover over "Following": "Unfollow" (red) 820 + 821 + **Location:** 822 + 823 + - User profile header 824 + - Brew author byline (small follow button) 825 + - Followers/Following lists 826 + 827 + ### Comments Section 828 + 829 + **Layout:** 830 + 831 + - Threaded comments (indented replies) 832 + - Show comment count 833 + - "Load more" pagination (20 per page) 834 + - Sort by: Newest, Oldest, Most Liked 835 + 836 + **Comment Form:** 837 + 838 + - Textarea with mention support (@username autocomplete) 839 + - Optional rating (1-10 stars) 840 + - Cancel/Submit buttons 841 + - Character count (500 max) 842 + 843 + --- 844 + 845 + ## Migration & Rollout 846 + 847 + ### Step 1: Backend Deployment (Week 1) 848 + 849 + 1. Deploy firehose listener with like/follow indexing 850 + 2. Backfill existing likes/follows from firehose history 851 + 3. Test API endpoints in staging 852 + 4. Monitor BoltDB storage growth 853 + 854 + ### Step 2: Frontend Soft Launch (Week 2) 855 + 856 + 1. Deploy like button (feature flag: enabled for beta users) 857 + 2. Collect feedback 858 + 3. Fix bugs 859 + 860 + ### Step 3: Public Launch (Week 3) 861 + 862 + 1. Enable likes for all users 863 + 2. Announce on Bluesky: "You can now like brews on Arabica!" 864 + 3. Monitor server load 865 + 866 + ### Step 4: Follow Feature (Week 4-5) 867 + 868 + 1. Deploy follow indexing 869 + 2. Add follow button to profiles 870 + 3. Add "Following" feed filter 871 + 4. Launch import-from-Bluesky tool 872 + 873 + ### Step 5: Comments (Week 6-8) 874 + 875 + 1. Define and publish comment lexicon 876 + 2. Deploy comment indexing 877 + 3. Add comment UI 878 + 4. Test threading 879 + 880 + --- 881 + 882 + ## Future Enhancements 883 + 884 + ### Phase 5+: Advanced Social Features 885 + 886 + 1. **Notifications** 887 + - "X liked your brew" 888 + - "Y commented on your brew" 889 + - WebSocket-based real-time updates 890 + 891 + 2. **Social Discovery** 892 + - "Trending brews this week" 893 + - "Popular roasters" 894 + - "Top coffee influencers" 895 + 896 + 3. **Activity Feed** 897 + - "Your friend Alice brewed a new espresso" 898 + - "Bob rated a bean you liked" 899 + 900 + 4. **Lists & Collections** 901 + - "My favorite light roasts" (curated bean list) 902 + - "Seattle coffee shops" (location-based) 903 + 904 + 5. **Collaborative Brewing** 905 + - Share brew recipes 906 + - Clone someone's brew with credit 907 + - Brew challenges ("30 days of pour-over") 908 + 909 + 6. **Cross-App Features** 910 + - Share brew to Bluesky as a post (with photo) 911 + - Embed brew cards in Bluesky posts 912 + - "Post to Bluesky" button on brew creation 913 + 914 + --- 915 + 916 + ## Appendix A: Lexicon Files 917 + 918 + ### Proposed: `social.arabica.alpha.like.json` (NOT RECOMMENDED) 919 + 920 + If we decide NOT to use `app.bsky.feed.like`, here's our custom lexicon: 921 + 922 + ```json 923 + { 924 + "lexicon": 1, 925 + "id": "social.arabica.alpha.like", 926 + "defs": { 927 + "main": { 928 + "type": "record", 929 + "key": "tid", 930 + "description": "A like on a brew, bean, roaster, grinder, or brewer", 931 + "record": { 932 + "type": "object", 933 + "required": ["subject", "createdAt"], 934 + "properties": { 935 + "subject": { 936 + "type": "ref", 937 + "ref": "com.atproto.repo.strongRef", 938 + "description": "The record being liked" 939 + }, 940 + "createdAt": { 941 + "type": "string", 942 + "format": "datetime" 943 + } 944 + } 945 + } 946 + } 947 + } 948 + } 949 + ``` 950 + 951 + ### Proposed: `social.arabica.alpha.follow.json` (NOT RECOMMENDED) 952 + 953 + ```json 954 + { 955 + "lexicon": 1, 956 + "id": "social.arabica.alpha.follow", 957 + "defs": { 958 + "main": { 959 + "type": "record", 960 + "key": "tid", 961 + "description": "Following another coffee enthusiast", 962 + "record": { 963 + "type": "object", 964 + "required": ["subject", "createdAt"], 965 + "properties": { 966 + "subject": { 967 + "type": "string", 968 + "format": "did", 969 + "description": "DID of the user being followed" 970 + }, 971 + "createdAt": { 972 + "type": "string", 973 + "format": "datetime" 974 + } 975 + } 976 + } 977 + } 978 + } 979 + } 980 + ``` 981 + 982 + --- 983 + 984 + ## Appendix B: Estimated Effort 985 + 986 + | Phase | Feature | Backend | Frontend | Testing | Total | 987 + | --------- | ----------------- | ------- | -------- | ------- | ------------------------ | 988 + | 1 | Likes | 5 days | 3 days | 2 days | **10 days** | 989 + | 2 | Follows | 7 days | 5 days | 3 days | **15 days** | 990 + | 3 | Comments | 8 days | 6 days | 4 days | **18 days** | 991 + | 4 | Feed Enhancements | 4 days | 4 days | 2 days | **10 days** | 992 + | **Total** | | | | | **53 days (10.6 weeks)** | 993 + 994 + --- 995 + 996 + ## Appendix C: Open Questions 997 + 998 + 1. **Moderation**: How do we handle spam comments or abusive likes? 999 + - Use AT Protocol's label system? 1000 + - Admin moderation tools? 1001 + 1002 + 2. **Privacy**: Should follows be private? 1003 + - Current plan: Public (like Bluesky) 1004 + - Could add private follows later 1005 + 1006 + 3. **Notifications**: What delivery mechanism? 1007 + - WebSocket for real-time? 1008 + - Polling API? 1009 + - Email digests? 1010 + 1011 + 4. **Analytics**: Track engagement metrics? 1012 + - Like/comment rates 1013 + - User retention 1014 + - Popular content 1015 + 1016 + 5. **Mobile**: When to build native apps? 1017 + - After web is stable 1018 + - Consider PWA first 1019 + 1020 + --- 1021 + 1022 + ## Conclusion 1023 + 1024 + This plan provides a **phased, pragmatic approach** to adding social features to Arabica. By **reusing Bluesky's `like` and `follow` lexicons**, we gain: 1025 + 1026 + - ✅ Cross-app discoverability 1027 + - ✅ Simpler implementation 1028 + - ✅ Follow import from Bluesky 1029 + - ✅ Future-proof social graph 1030 + 1031 + While **custom comments** allow: 1032 + 1033 + - ✅ Coffee-specific features (ratings, tasting notes) 1034 + - ✅ Cleaner separation from Bluesky threads 1035 + - ✅ Control over threading UX 1036 + 1037 + **Next Steps:** 1038 + 1039 + 1. Review and approve this plan 1040 + 2. Begin Phase 1 (Likes) implementation 1041 + 3. Iterate based on user feedback 1042 + 4. Expand to follows and comments 1043 + 1044 + **Timeline:** ~11 weeks for all phases (with 1 developer) 1045 + 1046 + --- 1047 + 1048 + **Document Status:** Draft for Review 1049 + **Last Updated:** January 25, 2026 1050 + **Author:** AI Assistant (with human review pending)
+157 -18
frontend/src/components/FeedCard.svelte
··· 13 13 function hasValue(val) { 14 14 return val !== null && val !== undefined && val !== ""; 15 15 } 16 + 17 + function formatTemperature(temp) { 18 + if (!hasValue(temp)) return null; 19 + const unit = temp <= 100 ? "C" : "F"; 20 + return `${temp}°${unit}`; 21 + } 22 + 23 + function formatTime(seconds) { 24 + if (!hasValue(seconds)) return null; 25 + const mins = Math.floor(seconds / 60); 26 + const secs = seconds % 60; 27 + if (mins > 0) { 28 + return `${mins}:${secs.toString().padStart(2, "0")}`; 29 + } 30 + return `${seconds}s`; 31 + } 32 + 33 + function safeWebsiteURL(url) { 34 + if (!url) return null; 35 + if (url.startsWith("https://") || url.startsWith("http://")) { 36 + return url; 37 + } 38 + return null; 39 + } 16 40 </script> 17 41 18 42 <div ··· 138 162 </div> 139 163 {/if} 140 164 165 + <!-- Brew parameters in compact grid --> 166 + <div class="grid grid-cols-2 gap-x-4 gap-y-1 text-xs text-brown-700"> 167 + {#if item.Brew.grinder_obj} 168 + <div> 169 + <span class="text-brown-600">Grinder:</span> 170 + {item.Brew.grinder_obj.name}{#if item.Brew.grind_size} 171 + ({item.Brew.grind_size}){/if} 172 + </div> 173 + {:else if item.Brew.grind_size} 174 + <div> 175 + <span class="text-brown-600">Grind:</span> 176 + {item.Brew.grind_size} 177 + </div> 178 + {/if} 179 + {#if item.Brew.pours && item.Brew.pours.length > 0} 180 + <div class="col-span-2"> 181 + <span class="text-brown-600">Pours:</span> 182 + {#each item.Brew.pours as pour} 183 + <div class="pl-2 text-brown-600"> 184 + • {pour.water_amount}g @ {formatTime(pour.time_seconds)} 185 + </div> 186 + {/each} 187 + </div> 188 + {:else if hasValue(item.Brew.water_amount)} 189 + <div> 190 + <span class="text-brown-600">Water:</span> 191 + {item.Brew.water_amount}g 192 + </div> 193 + {/if} 194 + {#if formatTemperature(item.Brew.temperature)} 195 + <div> 196 + <span class="text-brown-600">Temp:</span> 197 + {formatTemperature(item.Brew.temperature)} 198 + </div> 199 + {/if} 200 + {#if hasValue(item.Brew.time_seconds)} 201 + <div> 202 + <span class="text-brown-600">Time:</span> 203 + {formatTime(item.Brew.time_seconds)} 204 + </div> 205 + {/if} 206 + </div> 207 + 141 208 <!-- Notes --> 142 209 {#if item.Brew.tasting_notes} 143 210 <div 144 - class="mt-2 text-sm text-brown-800 italic border-l-2 border-brown-300 pl-3" 211 + class="mt-3 text-sm text-brown-800 italic border-t border-brown-200 pt-2" 145 212 > 146 213 "{item.Brew.tasting_notes}" 147 214 </div> 148 215 {/if} 216 + 217 + <!-- View button --> 218 + <div class="mt-3 border-t border-brown-200 pt-3"> 219 + <a 220 + href="/brews/{item.Author.did}/{item.Brew.rkey}" 221 + on:click|preventDefault={() => 222 + navigate(`/brews/${item.Author.did}/${item.Brew.rkey}`)} 223 + class="inline-flex items-center text-sm font-medium text-brown-700 hover:text-brown-900 hover:underline" 224 + > 225 + View full details → 226 + </a> 227 + </div> 149 228 </div> 150 229 {:else if item.RecordType === "bean" && item.Bean} 151 230 <div 152 231 class="bg-white/60 backdrop-blur rounded-lg p-3 border border-brown-200" 153 232 > 154 - <div class="font-semibold text-brown-900"> 155 - {item.Bean.name || item.Bean.origin} 233 + <div class="text-base mb-2"> 234 + <span class="font-bold text-brown-900"> 235 + {item.Bean.name || item.Bean.origin} 236 + </span> 237 + {#if item.Bean.roaster?.name} 238 + <span class="text-brown-700"> from {item.Bean.roaster.name}</span> 239 + {/if} 240 + </div> 241 + <div class="text-sm text-brown-700 space-y-1"> 242 + {#if item.Bean.origin} 243 + <div><span class="text-brown-600">Origin:</span> {item.Bean.origin}</div> 244 + {/if} 245 + {#if item.Bean.roast_level} 246 + <div> 247 + <span class="text-brown-600">Roast:</span> 248 + {item.Bean.roast_level} 249 + </div> 250 + {/if} 251 + {#if item.Bean.process} 252 + <div><span class="text-brown-600">Process:</span> {item.Bean.process}</div> 253 + {/if} 254 + {#if item.Bean.description} 255 + <div class="mt-2 text-brown-800 italic"> 256 + "{item.Bean.description}" 257 + </div> 258 + {/if} 156 259 </div> 157 - {#if item.Bean.origin}<div class="text-sm text-brown-700"> 158 - 📍 {item.Bean.origin} 159 - </div>{/if} 160 260 </div> 161 261 {:else if item.RecordType === "roaster" && item.Roaster} 162 262 <div 163 263 class="bg-white/60 backdrop-blur rounded-lg p-3 border border-brown-200" 164 264 > 165 - <div class="font-semibold text-brown-900">🏭 {item.Roaster.name}</div> 166 - {#if item.Roaster.location}<div class="text-sm text-brown-700"> 167 - 📍 {item.Roaster.location} 168 - </div>{/if} 265 + <div class="text-base mb-2"> 266 + <span class="font-bold text-brown-900">{item.Roaster.name}</span> 267 + </div> 268 + <div class="text-sm text-brown-700 space-y-1"> 269 + {#if item.Roaster.location} 270 + <div> 271 + <span class="text-brown-600">Location:</span> 272 + {item.Roaster.location} 273 + </div> 274 + {/if} 275 + {#if safeWebsiteURL(item.Roaster.website)} 276 + <div> 277 + <span class="text-brown-600">Website:</span> 278 + <a 279 + href={safeWebsiteURL(item.Roaster.website)} 280 + target="_blank" 281 + rel="noopener noreferrer" 282 + class="text-brown-800 hover:underline" 283 + >{safeWebsiteURL(item.Roaster.website)}</a 284 + > 285 + </div> 286 + {/if} 287 + </div> 169 288 </div> 170 289 {:else if item.RecordType === "grinder" && item.Grinder} 171 290 <div 172 291 class="bg-white/60 backdrop-blur rounded-lg p-3 border border-brown-200" 173 292 > 174 - <div class="font-semibold text-brown-900">⚙️ {item.Grinder.name}</div> 175 - {#if item.Grinder.grinder_type}<div class="text-sm text-brown-700"> 176 - {item.Grinder.grinder_type} 177 - </div>{/if} 293 + <div class="text-base mb-2"> 294 + <span class="font-bold text-brown-900">{item.Grinder.name}</span> 295 + </div> 296 + <div class="text-sm text-brown-700 space-y-1"> 297 + {#if item.Grinder.grinder_type} 298 + <div> 299 + <span class="text-brown-600">Type:</span> 300 + {item.Grinder.grinder_type} 301 + </div> 302 + {/if} 303 + {#if item.Grinder.burr_type} 304 + <div> 305 + <span class="text-brown-600">Burr:</span> 306 + {item.Grinder.burr_type} 307 + </div> 308 + {/if} 309 + {#if item.Grinder.notes} 310 + <div class="mt-2 text-brown-800 italic">"{item.Grinder.notes}"</div> 311 + {/if} 312 + </div> 178 313 </div> 179 314 {:else if item.RecordType === "brewer" && item.Brewer} 180 315 <div 181 316 class="bg-white/60 backdrop-blur rounded-lg p-3 border border-brown-200" 182 317 > 183 - <div class="font-semibold text-brown-900">☕ {item.Brewer.name}</div> 184 - {#if item.Brewer.brewer_type}<div class="text-sm text-brown-700"> 185 - {item.Brewer.brewer_type} 186 - </div>{/if} 318 + <div class="text-base mb-2"> 319 + <span class="font-bold text-brown-900">{item.Brewer.name}</span> 320 + </div> 321 + {#if item.Brewer.description} 322 + <div class="text-sm text-brown-800 italic"> 323 + "{item.Brewer.description}" 324 + </div> 325 + {/if} 187 326 </div> 188 327 {/if} 189 328 </div>
+2 -2
frontend/src/components/Footer.svelte
··· 39 39 </li> 40 40 <li> 41 41 <a 42 - href="https://github.com/arabica-social/arabica" 42 + href="https://tangled.org/arabica.social/arabica" 43 43 target="_blank" 44 44 rel="noopener noreferrer" 45 45 class="text-brown-300 hover:text-white transition-colors" 46 46 > 47 - GitHub 47 + Source 48 48 </a> 49 49 </li> 50 50 </ul>
+2 -2
frontend/src/routes/About.svelte
··· 175 175 <p class="text-brown-800 leading-relaxed"> 176 176 Arabica is open source software. You can view the code, contribute, or 177 177 even run your own instance. Visit our <a 178 - href="https://github.com/ptdewey/arabica" 178 + href="https://tangled/arabica.social/arabica" 179 179 class="text-brown-700 hover:underline font-medium" 180 180 target="_blank" 181 - rel="noopener noreferrer">GitHub repository</a 181 + rel="noopener noreferrer">Tangled repository</a 182 182 > to learn more. 183 183 </p> 184 184 </section>
+41 -37
frontend/src/routes/BrewForm.svelte
··· 243 243 </div> 244 244 {/if} 245 245 246 - <form on:submit|preventDefault={handleSubmit} class="space-y-4 md:space-y-6"> 246 + <form 247 + on:submit|preventDefault={handleSubmit} 248 + class="space-y-4 md:space-y-6" 249 + > 247 250 <!-- Bean Selection --> 248 251 <div> 249 252 <label ··· 375 378 <label 376 379 for="water-amount" 377 380 class="block text-sm font-medium text-brown-900 mb-2" 378 - >Water Amount (ml)</label 381 + >Water Amount (optional)</label 379 382 > 380 383 <input 381 384 id="water-amount" ··· 387 390 /> 388 391 </div> 389 392 390 - <!-- Water Temperature --> 391 - <div> 392 - <label 393 - for="water-temp" 394 - class="block text-sm font-medium text-brown-900 mb-2" 395 - >Water Temperature (°C)</label 396 - > 397 - <input 398 - id="water-temp" 399 - type="number" 400 - bind:value={form.water_temp} 401 - step="0.1" 402 - placeholder="e.g. 93" 403 - class="w-full rounded-lg border-2 border-brown-300 shadow-sm focus:border-brown-600 focus:ring-brown-600 text-base py-2 md:py-3 px-3 md:px-4 bg-white" 404 - /> 405 - </div> 406 - 407 - <!-- Brew Time --> 408 - <div> 409 - <label 410 - for="brew-time" 411 - class="block text-sm font-medium text-brown-900 mb-2" 412 - >Total Brew Time (seconds)</label 413 - > 414 - <input 415 - id="brew-time" 416 - type="number" 417 - bind:value={form.brew_time} 418 - step="1" 419 - placeholder="e.g. 210" 420 - class="w-full rounded-lg border-2 border-brown-300 shadow-sm focus:border-brown-600 focus:ring-brown-600 text-base py-2 md:py-3 px-3 md:px-4 bg-white" 421 - /> 422 - </div> 423 - 424 393 <!-- Pours --> 425 394 <div> 426 395 <div class="flex items-center justify-between mb-2"> ··· 442 411 <div 443 412 class="flex gap-2 items-center bg-brown-50 p-2 md:p-3 rounded-lg border border-brown-200" 444 413 > 445 - <span class="text-xs md:text-sm font-medium text-brown-700 min-w-[50px] md:min-w-[60px]" 414 + <span 415 + class="text-xs md:text-sm font-medium text-brown-700 min-w-[50px] md:min-w-[60px]" 446 416 >Pour {i + 1}:</span 447 417 > 448 418 <input ··· 470 440 {/if} 471 441 </div> 472 442 443 + <!-- Water Temperature --> 444 + <div> 445 + <label 446 + for="water-temp" 447 + class="block text-sm font-medium text-brown-900 mb-2" 448 + >Water Temperature (°C)</label 449 + > 450 + <input 451 + id="water-temp" 452 + type="number" 453 + bind:value={form.water_temp} 454 + step="0.1" 455 + placeholder="e.g. 93" 456 + class="w-full rounded-lg border-2 border-brown-300 shadow-sm focus:border-brown-600 focus:ring-brown-600 text-base py-2 md:py-3 px-3 md:px-4 bg-white" 457 + /> 458 + </div> 459 + 460 + <!-- Brew Time --> 461 + <div> 462 + <label 463 + for="brew-time" 464 + class="block text-sm font-medium text-brown-900 mb-2" 465 + >Total Brew Time (seconds)</label 466 + > 467 + <input 468 + id="brew-time" 469 + type="number" 470 + bind:value={form.brew_time} 471 + step="1" 472 + placeholder="e.g. 210" 473 + class="w-full rounded-lg border-2 border-brown-300 shadow-sm focus:border-brown-600 focus:ring-brown-600 text-base py-2 md:py-3 px-3 md:px-4 bg-white" 474 + /> 475 + </div> 476 + 473 477 <!-- Rating --> 474 478 <div> 475 479 <label
+21 -5
frontend/src/routes/Brews.svelte
··· 132 132 <h3 class="text-xl font-bold text-brown-900"> 133 133 {brew.bean.name || brew.bean.origin || "Unknown Bean"} 134 134 </h3> 135 - {#if brew.bean.Roaster?.Name} 136 - <p class="text-sm text-brown-700 mb-2"> 137 - 🏭 {brew.bean.roaster.name} 138 - </p> 139 - {/if} 135 + <div class="text-sm text-brown-700 mb-2 space-y-0.5"> 136 + {#if brew.bean.roaster} 137 + <p>🏭 {brew.bean.roaster.name}</p> 138 + {/if} 139 + {#if brew.bean.roast_level || brew.bean.origin || brew.bean.process} 140 + <p class="flex flex-wrap gap-x-3"> 141 + {#if brew.bean.roast_level} 142 + <span>🔥 {brew.bean.roast_level}</span> 143 + {/if} 144 + {#if brew.bean.origin} 145 + <span>🌍 {brew.bean.origin}</span> 146 + {/if} 147 + {#if brew.bean.process} 148 + <span>⚗️ {brew.bean.process}</span> 149 + {/if} 150 + </p> 151 + {/if} 152 + </div> 140 153 {:else} 141 154 <h3 class="text-xl font-bold text-brown-900"> 142 155 Unknown Bean ··· 163 176 {:else if brew.method} 164 177 <span>☕ {brew.method}</span> 165 178 {/if} 179 + {#if brew.grinder_obj} 180 + <span>⚙️ {brew.grinder_obj.name}</span> 181 + {/if} 166 182 {#if hasValue(brew.temperature)} 167 183 <span>🌡️ {formatTemperature(brew.temperature)}</span> 168 184 {/if}
+3
justfile
··· 1 + [private] 2 + default: build-ui run 3 + 1 4 run: 2 5 @LOG_LEVEL=debug LOG_FORMAT=console go run cmd/arabica-server/main.go -known-dids known-dids.txt 3 6
+2 -2
static/app/index.html
··· 22 22 23 23 <!-- Web Manifest for PWA --> 24 24 <link rel="manifest" href="/static/manifest.json" /> 25 - <script type="module" crossorigin src="/static/app/assets/index-CmBOU5Wv.js"></script> 26 - <link rel="stylesheet" crossorigin href="/static/app/assets/index-Cd6pn8d5.css"> 25 + <script type="module" crossorigin src="/static/app/assets/index-qNP6okvG.js"></script> 26 + <link rel="stylesheet" crossorigin href="/static/app/assets/index-Dnf8PWVW.css"> 27 27 </head> 28 28 <body class="bg-brown-50 text-brown-900 min-h-screen"> 29 29 <div id="app"></div>

History

1 round 0 comments
sign up or login to add to the discussion
pdewey.com submitted #0
1 commit
expand
feat: improved styling of brews list page
expand 0 comments
closed without merging