mobile bluesky app made with flutter lazurite.stormlightlabs.org/
mobile bluesky flutter
3
fork

Configure Feed

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

docs: reorganize & refresh docs (#27)

* docs: first pass - clean up completed tasks files and reorganize specs

* docs: clean-up planning notes

* chore: remove out-of-date screenshots

* rewrite feature list

* chore: update changelog

authored by

Owais and committed by
GitHub
b3c03aa4 3bd6f128

+728 -2944
+16
CHANGELOG.md
··· 1 1 # CHANGELOG 2 2 3 + ## v1.0.0 (Alpha 6 - unreleased) 4 + 5 + ### Added 6 + 7 + - Edit profile screen with support for updating display name, bio, images, pronouns, 8 + and website 9 + - Display pronouns and website (with link to browser) on profile screens 10 + 11 + ### Changed 12 + 13 + - Reorganized dev docs 14 + 15 + ## v1.0.0 (Alpha 5) 16 + 17 + TODO 18 + 3 19 ## v1.0.0 (Alpha 4) 4 20 5 21 ### Changed
+51 -47
README.md
··· 1 1 <!-- markdownlint-disable MD041 --> 2 + ![Lazurite Banner](./docs/images/hero.png) 2 3 3 - ![Lazurite Hero](./docs/images/hero.png) 4 + # Lazurite 4 5 5 - Lazurite is a cross-platform Bluesky client that *rocks*[^1] built with Flutter and Dart using Material You (M3) design. 6 + Lazurite is a cross-platform Bluesky & BlackSky client that *rocks*[^1] built with 7 + Flutter and Dart using Material You (M3) design. 6 8 7 9 Download it on our [releases page](https://github.com/stormlightlabs/lazurite/releases/). 8 10 9 11 <!-- markdownlint-disable MD033 --> 10 - [<img src="https://raw.githubusercontent.com/ImranR98/Obtainium/main/assets/graphics/badge_obtainium.png" alt="Get Lazurite on Obtainium" height="48">](http://apps.obtainium.imranr.dev/redirect.html?r=obtainium://add/https://github.com/stormlightlabs/lazurite/releases) 12 + [<img src="https://raw.githubusercontent.com/ImranR98/Obtainium/main/assets/graphics/badge_obtainium.png" alt="Get Lazurite on Obtainium" height="48px">](http://apps.obtainium.imranr.dev/redirect.html?r=obtainium://add/https://github.com/stormlightlabs/lazurite/releases) 11 13 12 14 ## Features 13 15 14 - ### Home Feed & Composer 16 + ### Core Bluesky & BlackSky Client 15 17 16 - | Home Feed | Composer | Profile | 17 - | --------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | ---------------------------------------------------------------- | 18 - | ![Home Feed](./docs/images/home-feed.png) | ![Compose Screenshot](https://placehold.co/400x800/4CAF50/FFFFFF?text=Compose+Screenshot) | ![Profile](./docs/images/profile.png) | 19 - | View your personal timeline with support for threads and media. | Create new posts with rich text and media attachments. Supports replies and quoting. | View detailed actor profiles, including their feed and metadata. | 18 + - Home timeline, custom feeds, feed pinning, and feed reordering. 19 + - Post threads, replies, quote posts, reposts, likes, saves, and sharing. 20 + - Rich post composition with facets, images, video uploads, replies, quotes, drafts, and scheduling. 21 + - Profile screens with author feeds, likes, starter packs, lists, follows, followers, and profile actions. 22 + - Search for posts, actors, hashtags, starter packs, and profile-scoped posts. 23 + - Notifications, direct messages, lists, starter packs, labelers, and moderation preferences. 24 + - In-app image viewer, video playback, media sharing, and media download support. 20 25 21 - ### Search & Profile 26 + ### Local And Offline Features 22 27 23 - | Search | About | DevTools | 24 - | ----------------------------------------------------- | ------------------------------------ | ------------------------------------------------------------------------------------- | 25 - | ![Search Results](./docs/images/search.png) | ![About](./docs/images/about.png) | ![DevTools](./docs/images/dev-tools.png) | 26 - | Discover people and posts across the Bluesky network. | About (showing Rose Pine Moon theme) | Built-in logs and developer utilities for exploring the AT Protocol (Rose Pine Dawn). | 28 + - Drift-backed local cache for the first page of feeds and profile data. 29 + - Local drafts with account-scoped reply, quote, and media context. 30 + - Local saved posts, liked-post sync, and search history. 31 + - On-device semantic search for saved and liked posts using MiniLM embeddings and ObjectBox vector search. 32 + - Offline-aware screens that render cached data and disable network-only actions when needed. 27 33 28 - ### Offline Support & Drafts 34 + ### Account And Protocol Tools 29 35 30 - Local-only drafts and caching powered by Drift (SQLite). 36 + - OAuth login, account switching, session restore, and debug app-password login. 37 + - Provider-aware AppView routing for Bluesky, Blacksky, and validated custom AppViews. 38 + - Follow audit for deleted, deactivated, suspended, blocking, hidden, and self-follow records. 39 + - Profile context powered by [Constellation](https://constellation.microcosm.blue) backlinks. 40 + - AT Protocol Dev Tools for browsing PDS repositories, collections, and records as JSON. 41 + - In-app logs with level filters, search, sharing, and export for debugging. 31 42 32 - - **Drafts:** Save posts locally and publish later. 33 - - **Search History:** Persisted local search history. 34 - - **Saved Feeds:** Manage and pin your favorite feeds. 43 + ### Customization 35 44 36 - ## What Lazurite Offers Beyond Bluesky 45 + - Five theme palettes: Lazurite™️[^2], Rose Pine, Catppuccin, Nord, and Oxocarbon. 46 + - Light and dark variants built on Material 3. 47 + - Card and Compact feed layouts. 48 + - Configurable thread auto-collapse depth. 37 49 38 - ### Available Now 50 + ## Planned 51 + 52 + ### Reading And Media 53 + 54 + - Gallery mode for browsing media-heavy feeds and post threads. 55 + - Last-read position for resuming timelines. 56 + - RSS feed export for public Bluesky profiles. 57 + 58 + ### Publishing 59 + 60 + - Markdown rendering for post bodies. 61 + - Auto-threading for long posts. 39 62 40 - - **Semantic Search:** On-device vector embeddings (all-MiniLM-L6-v2) let you search saved 41 - and liked posts by meaning, not just keywords. 42 - - **Post Scheduling:** Write posts now, publish them later. 43 - - **Follow Audit:** Bulk-analyze your follows to find deleted, deactivated, suspended, or 44 - blocking accounts. Batch unfollow in one tap like [clean follows](https://cleanfollow-bsky.pages.dev/) 45 - - **Constellation Integration:** See who has blocked you and which lists you appear on, 46 - powered by [Constellation](https://constellation.microcosm.blue) backlinks. 47 - - **AT Protocol Dev Tools:** Browse any user's PDS repository, inspect collections and 48 - individual records as JSON, like an in-app [pds.ls](https://pds.ls/). 49 - - **Rich Theming:** Five full palettes (Lazurite™️[^2], Rose Pine, Catppuccin, Nord, Oxocarbon), 50 - each with light and dark variants, built on Material 3. 51 - - **Offline First:** First page of feeds is cached locally; drafts, search history, and saved 52 - posts persist in an on-device database. 53 - - **Local Drafts:** Auto-saved to the database, surviving crashes and force-closes. Multiple 54 - drafts per account with full reply/quote/media context. 55 - - **Layout Options:** Toggle between Card and Compact feed views. Configure thread 56 - auto-collapse depth (off, 1–6 levels). 57 - - **In-App Logs:** Filter by level, full-text search, share or export, useful for 58 - debugging and AT Protocol development. 63 + ### Customization 64 + 65 + - User-selectable serif, sans-serif, and monospace typefaces. 66 + - Expanded layout controls for feed density and feed architecture. 59 67 60 - ### On the Roadmap 68 + ### Protocol And Maintenance 61 69 62 - - **RSS Feed Export:** View and export any public Bluesky profile as an RSS feed. 63 - - **Custom Fonts:** User-selectable serif, sans-serif, and monospace typefaces across the 64 - entire app. 65 - - **Markdown Posts:** Toggleable Markdown rendering in post bodies. 66 - - **Firehose & Jetstream Viewers:** Live AT Protocol event streams inside Dev Tools. 67 - - **Auto-Threading:** Automatically split long posts into threaded replies. 68 - - **Last Read Position:** Resume your timeline exactly where you left off. 70 + - Social graph visualization. 71 + - Firehose and Jetstream viewers inside Dev Tools. 72 + - Expanded notification settings, permission flows, and remote push validation. 69 73 70 74 ## Architecture 71 75
+65
docs/dev/animate.md
··· 1 + --- 2 + title: Motion 3 + updated: 2026-05-07 4 + --- 5 + 6 + Lazurite uses small motion cues to show navigation, feedback, and loading 7 + state. Motion should help the user understand what changed. It should not draw 8 + attention to itself. 9 + 10 + `flutter_animate` is the shared animation package for widget-level effects such 11 + as fades, slides, scales, shimmer, and staggered entrances. Use raw controllers 12 + only when the interaction needs custom timing or scroll-driven behavior that 13 + the shared package does not fit. 14 + 15 + ## Shared Tokens 16 + 17 + Animation durations, curves, and stagger offsets belong in 18 + `lib/core/theme/animation_tokens.dart`. Shared helpers live in 19 + `lib/core/theme/animation_utils.dart`. Widget files should use those tokens 20 + instead of local magic numbers. This keeps feed cards, snackbars, buttons, and 21 + empty states moving at the same pace. 22 + 23 + Common timing buckets are fast feedback, normal entrance or exit, and slower 24 + state transitions. Staggered lists cap the number of offset items so pagination 25 + does not create long delayed sequences. 26 + 27 + ## Where Motion Is Used 28 + 29 + Feed cards, notification rows, search results, follow-audit rows, list members, 30 + and saved posts use a one-time entrance as new items appear. Track seen item 31 + keys so scrolling back does not replay the animation. 32 + 33 + Like, repost, and bookmark controls use short scale feedback when toggled. 34 + Bottom navigation uses a small active-icon transition. Floating action buttons 35 + scale in when they appear and scale out when removed. Snackbars enter from the 36 + bottom and dismiss quickly. 37 + 38 + Loading placeholders use shimmer where it communicates waiting for content with 39 + known shape. Empty states fade and scale in once, avoiding abrupt swaps between 40 + loading and empty UI. 41 + 42 + Profile banner parallax is scroll-driven and should stay separate from 43 + `flutter_animate`. It is implemented with scroll offset and transforms so it 44 + does not trigger layout work. 45 + 46 + ## Reduced Motion 47 + 48 + Respect `MediaQuery.disableAnimations`. When the platform asks for reduced 49 + motion, skip nonessential transitions. Route changes can use a short crossfade, 50 + and loading indicators may continue when they communicate progress rather than 51 + decoration. 52 + 53 + Reduced-motion behavior needs widget coverage. Tests should mount the widget 54 + with disabled animations and verify that optional animation wrappers are not in 55 + the tree or that the final state is reached without waiting for motion. 56 + 57 + ## Performance And Tests 58 + 59 + Prefer transform and opacity effects because they stay on the compositor path. 60 + Avoid animating dimensions in scrolling lists. Check feed and profile surfaces 61 + with Flutter performance tools when adding broad motion. 62 + 63 + Tests should settle animations before checking final state. Token tests guard 64 + against accidental timing drift, while focused widget tests cover action 65 + feedback, reduced motion, shimmer placeholders, and one-time list entrances.
+70
docs/dev/compose-notifications-actions.md
··· 1 + --- 2 + title: Compose, Notifications, And Actions 3 + updated: 2026-05-07 4 + --- 5 + 6 + Compose, notification polling, post actions, profile actions, and saved posts 7 + touch local state and network writes. Use optimistic UI only where rollback 8 + behavior is clear and tested. 9 + 10 + ## Compose 11 + 12 + `ComposeBloc` in `lib/features/compose/bloc/compose_bloc.dart` tracks text, 13 + facets, media, reply refs, quote refs, language tags, and submission status. 14 + Posts are written through `ComposeRepository` with `com.atproto.repo.createRecord` 15 + in the `app.bsky.feed.post` collection. 16 + 17 + Text length is counted with Dart grapheme clusters, not code units. The submit 18 + action is disabled for empty text and over-limit posts. Rich text facets are 19 + detected before submission and rendered as a live preview while the user types. 20 + 21 + Images upload through `com.atproto.repo.uploadBlob` and are embedded as 22 + `app.bsky.embed.images`. A post may include up to four images. Video upload 23 + uses `app.bsky.video.uploadVideo`, then polls job status until the processed 24 + blob is available. Video and image embeds are mutually exclusive; switching 25 + between them should ask before replacing existing attachments. 26 + 27 + Drafts are account-scoped Drift rows. Network failure and explicit save both 28 + persist the draft. Scheduled posts extend the draft model with a future publish 29 + time and rely on platform background scheduling to retry when connectivity 30 + returns. 31 + 32 + ## Notifications 33 + 34 + `NotificationBloc` in `lib/features/notifications/bloc` owns polling state. 35 + Polling notifications use `app.bsky.notification.listNotifications`, 36 + `getUnreadCount`, and `updateSeen`. Notifications are grouped by day and render 37 + author, reason, reason icon, read state, and an optional post preview. 38 + 39 + Foreground unread polling runs on an interval while the app is active. Opening 40 + the notifications screen marks current notifications as seen. Tapping a 41 + notification routes to the relevant post or profile. Later push notification 42 + work builds on this navigation and seen-state model. 43 + 44 + ## Post And Profile Actions 45 + 46 + Likes, reposts, follows, and blocks are AT Protocol records. Muting is a server 47 + procedure call. `PostActionRepository` and `ProfileActionRepository` should 48 + derive delete keys from viewer state URIs rather than guessing record keys. 49 + 50 + Post actions manage like, repost, reply, share, save, report, and copy-link 51 + behavior. Profile actions manage follow, mute, block, report, DID copy, and 52 + profile sharing. Destructive actions require confirmation where user intent 53 + could be ambiguous. 54 + 55 + Optimistic updates immediately adjust icon state and counts, run the network 56 + request, then reconcile with the server response. On failure, state rolls back 57 + and the user receives a snackbar. Tests should cover success, rollback, and 58 + viewer-state hydration. 59 + 60 + ## Saved Posts 61 + 62 + Saved posts are private and local-only. `SavedPostsCubit` reads and writes 63 + Drift rows with 64 + `account_did`, `post_uri`, serialized post JSON, and `saved_at`. The table has a 65 + unique account/post constraint so repeated saves update one row instead of 66 + creating duplicates. 67 + 68 + The save action is shown from post controls and overflow menus. Saved posts are 69 + read back through a Cubit that exposes both the saved-post list and a quick 70 + lookup stream for filled bookmark state in feeds.
+67
docs/dev/feeds-search-logging.md
··· 1 + --- 2 + title: Feeds, Search, And Logging 3 + updated: 2026-05-07 4 + --- 5 + 6 + Feeds, search, logging, and the PDS explorer share two implementation rules: 7 + repositories hide network details, and UI state stays explicit enough to test 8 + without live services. 9 + 10 + ## Logging 11 + 12 + `AppLogger` in `lib/core/logging/app_logger.dart` is the shared logging entry 13 + point. Feature code should log through that wrapper so filtering, redaction, 14 + file output, and in-app viewing stay consistent. 15 + 16 + Console logging is development-only. File logging is available in all builds 17 + and writes rotated daily files in the app documents directory. HTTP logging 18 + must redact authorization data and avoid full request or response bodies. Use 19 + summary fields, route names, status codes, and bounded body previews. 20 + 21 + The log viewer in `lib/features/logs` reads persisted log files from disk. It 22 + supports level filtering, text search, sharing the current log, and clearing 23 + logs with confirmation. File reads and UI filters belong in `LogViewerCubit` 24 + rather than a larger feature Bloc. 25 + 26 + ## Home Feeds 27 + 28 + `FeedRepository` in `lib/features/feed/data/feed_repository.dart` loads the 29 + Following timeline and user-pinned feed generators. The timeline uses 30 + `app.bsky.feed.getTimeline`. Generator feeds use `app.bsky.feed.getFeed` with 31 + the generator AT-URI. Both return hydrated post views and paginate by cursor. 32 + 33 + Pinned feed preferences live in the user's Bluesky preferences, backed by 34 + local Drift caching for offline startup. The `savedFeedsPrefV2` array controls 35 + which feeds appear as tabs, their order, and whether each entry is a timeline, 36 + generator, or list. Mutations should update local state optimistically, persist 37 + to the server, and reconcile with the returned preference state. 38 + 39 + The feed renderer reuses the shared post-card stack. Feed-specific code should 40 + own only pagination, refresh state, and feed identity. 41 + 42 + ## Search 43 + 44 + `SearchRepository` in `lib/features/search/data/search_repository.dart` owns 45 + post and actor search. Post search uses `app.bsky.feed.searchPosts`. Actor 46 + search uses `app.bsky.actor.searchActors`, and handle autocomplete uses the 47 + typeahead path documented in [typeahead.md](./typeahead.md). 48 + 49 + Search history is account-scoped in Drift. Insertions update the timestamp for 50 + repeat queries, and each account keeps a bounded recent history. UI actions 51 + include re-running a past search, deleting one entry, and clearing all entries. 52 + 53 + Search state should keep result type, query, pagination cursor, loading state, 54 + and error state separate. Avoid mixing actor typeahead into post-search 55 + pagination state; the shared typeahead repository and cubit own autocomplete. 56 + 57 + ## PDS Explorer 58 + 59 + The PDS explorer in `lib/features/devtools` is a developer tool for inspecting 60 + AT Protocol repositories. It resolves handles to DIDs, lists repo collections, 61 + paginates records, and opens individual records as formatted JSON. It also 62 + accepts AT-URIs so a developer can jump directly to a record. 63 + 64 + Explorer requests use `com.atproto.identity.resolveHandle`, 65 + `com.atproto.repo.describeRepo`, `com.atproto.repo.listRecords`, and 66 + `com.atproto.repo.getRecord`. The tool is read-only and should stay isolated 67 + from production feed or profile state.
+60
docs/dev/follow-hygiene.md
··· 1 + --- 2 + title: Follow Hygiene 3 + updated: 2026-05-07 4 + --- 5 + 6 + Follow hygiene audits the active account's follow records and helps the user 7 + remove dead or problematic follows in batches. It works from the user's own 8 + repo records, then hydrates followed accounts to classify their current state. 9 + 10 + ## Audit Flow 11 + 12 + `FollowAuditRepository` in `lib/features/profile/data/follow_audit_repository.dart` 13 + paginates `app.bsky.graph.follow` records through 14 + `com.atproto.repo.listRecords` for the active DID. Each record provides the 15 + follow URI, record key, and subject DID. The repository then batch-hydrates 16 + subjects with `app.bsky.actor.getProfiles`. 17 + 18 + Missing profiles are resolved individually so the app can distinguish deleted, 19 + deactivated, and suspended accounts where the API exposes that difference. 20 + Profiles that hydrate successfully are classified from viewer state and labels. 21 + The implemented statuses include deleted, deactivated, suspended, blocked by, 22 + blocking, mutual block, hidden, and self-follow. 23 + 24 + ## Rate Limits And Partial Results 25 + 26 + Profile hydration uses the SDK batch limit of 25 actors. Batches run with 27 + bounded concurrency and backoff on transient errors. Partial hydration failure 28 + does not discard the whole audit. The Cubit reports failed profile count and 29 + continues with the results it could classify. 30 + 31 + ## Batch Unfollow 32 + 33 + Batch removal uses `com.atproto.repo.applyWrites` delete operations against the 34 + `app.bsky.graph.follow` collection. Each delete operation uses the rkey from 35 + the original follow record URI. Writes are chunked to the protocol limit and 36 + executed sequentially. 37 + 38 + After each successful chunk, local state updates the completed count. If a chunk 39 + fails, the Cubit stops, reports how many accounts were already unfollowed, and 40 + leaves the remaining selected rows available for retry. 41 + 42 + ## UI Model 43 + 44 + `FollowAuditCubit` in `lib/features/profile/cubit/follow_audit_cubit.dart` 45 + drives the audit screen through fetching records, classifying profiles, ready, 46 + unfollowing, complete, and error states. The UI shows scan progress, category 47 + counts, selectable rows, visibility filters, and selected totals. 48 + 49 + Rows include checkbox, handle, truncated DID, status badge, and profile 50 + navigation. The screen renders empty and complete states for clean audits and 51 + finished removals. Entry points are guarded behind the authenticated account and 52 + appear where account maintenance actions are expected. 53 + 54 + ## Boundaries 55 + 56 + The audit does not infer inactivity from posting history. That would require 57 + fetching feeds for every followed account and would be expensive for large 58 + follow lists. There is also no automatic undo; unfollow is a protocol write. 59 + Any future undo flow should keep a local, time-bounded list of removed DIDs and 60 + make re-follow explicit.
+78
docs/dev/foundation.md
··· 1 + --- 2 + title: App Foundation 3 + updated: 2026-05-07 4 + --- 5 + 6 + Lazurite's app shell is feature-first Flutter code backed by `flutter_bloc`, 7 + `go_router`, and Drift. Cross-feature concerns live under `lib/core`; feature 8 + modules own their data, state, and presentation code under `lib/features`. 9 + 10 + State is modeled with small Bloc or Cubit classes. Presentation widgets render 11 + state and dispatch user intent; they should not own network or persistence 12 + rules. State classes are immutable and use explicit `copyWith` methods. Session 13 + or preference values that must survive app restarts are loaded from Drift rather 14 + than widget state. 15 + 16 + ## Persistence 17 + 18 + Drift is the primary local store. The schema lives in 19 + `lib/core/database/tables.dart` and `lib/core/database/app_database.dart`. 20 + Account, cached profile, cached post, and settings rows form the base local 21 + model. Any user-scoped row must include the active account DID, and repository 22 + queries should filter by that DID. Drift migrations remain mandatory for schema 23 + changes. 24 + 25 + The account table stores the DID, handle, and token material needed to restore 26 + the active session. Sensitive values must not be logged. Settings store theme 27 + choice, active account, and later feature preferences. 28 + 29 + ## Authentication 30 + 31 + Production login uses AT Protocol OAuth. Lazurite is a public native client 32 + with a hosted client metadata document and DPoP-bound tokens. The login flow 33 + resolves the user's account authority, sends the user through the system 34 + browser, captures the callback, exchanges the authorization code, and stores 35 + the resulting session for later restore. 36 + 37 + DPoP proof generation belongs in the auth layer. Every authenticated request 38 + uses the access token plus a fresh DPoP header. Refresh behavior is owned by 39 + the OAuth client and recovery services, not individual screens. 40 + 41 + App-password login exists only for debug paths. It calls 42 + `com.atproto.server.createSession`, stores the returned JWTs, and should remain 43 + guarded by debug flags. App-password sessions do not have the same protocol 44 + coverage as OAuth sessions, so production behavior should assume OAuth. 45 + 46 + Logout revokes or discards the active token state, clears the in-memory 47 + authentication state, and returns the user to login. Account switching builds 48 + on the same account table, but the active DID setting decides which session is 49 + currently hydrated. 50 + 51 + ## Profile Rendering 52 + 53 + Profiles are fetched through `app.bsky.actor.getProfile` or batched with 54 + `getProfiles`. The profile UI renders avatar, banner, display name, handle, 55 + description, counts, and any supported extended fields. 56 + 57 + Author feeds come from `app.bsky.feed.getAuthorFeed`, paginated with cursors. 58 + The feed renderer consumes hydrated `feedViewPost` values, including embeds, 59 + reply metadata, language tags, and viewer state. The same post-card renderer is 60 + used across later feed, search, saved-post, and list surfaces. 61 + 62 + Rich text facets use UTF-8 byte ranges, not Dart UTF-16 indices. Use 63 + `bluesky_text` or existing shared facet helpers to detect and render mentions, 64 + links, and tags. Mentions navigate to profile routes, hashtags navigate to 65 + topic or search routes, and normal links open externally unless a provider-aware 66 + internal route exists. 67 + 68 + ## Settings And Themes 69 + 70 + Settings expose system, light, and dark modes plus named theme palettes. Theme 71 + selection is persisted in Drift and applied through `ThemeMode` at the app 72 + root. New widgets should read theme data through the shared theme extensions in 73 + `lib/core/theme`. 74 + 75 + The implemented palette work includes built-in families such as Oxocarbon, 76 + Catppuccin, Nord, and Rose Pine. Each palette maps into Flutter `ThemeData` and 77 + `ColorScheme` objects. Feature code should depend on semantic colors from the 78 + theme, not raw palette constants, unless it is implementing the theme itself.
+6 -3
docs/dev/patterns.md
··· 1 - # Lazurite Code Patterns 1 + --- 2 + title: Code Patterns 3 + updated: 2026-05-07 4 + --- 2 5 3 6 This document captures recurring architectural and implementation patterns in the Lazurite codebase. 4 7 ··· 110 113 111 114 ## Testing Patterns 112 115 113 - - Per-file harness builders are standard (`buildSubject(...)`, plus helpers like `openSheet(...)`) so test bodies focus on behavior, not setup. 116 + - Per-file setup builders are standard (`buildSubject(...)`, plus helpers like `openSheet(...)`) so test bodies focus on behavior, not setup. 114 117 - Widget tests usually mount through `MaterialApp`/`Scaffold` for component tests, and `MaterialApp.router` + `GoRouter` for navigation tests. 115 118 - Interaction flow follows a consistent shape: 116 119 `pumpWidget` -> input (`tap`, `enterText`) -> `pump`/`pumpAndSettle` -> assertions on visible UI and side effects. ··· 120 123 - `MockClient` from `http/testing.dart` for HTTP-level contracts 121 124 - Small local fake implementations when protocol surfaces are complex 122 125 - Async UI timing is made explicit where animations/debounce are relevant (`pump(const Duration(...))` in sheet/search tests), and responsive behavior is exercised with `setSurfaceSize` in router/shell tests. 123 - - Defensive assertions are common for robustness: boundary-value checks in utility tests, null/empty/error-path repository tests, and `expect(tester.takeException(), isNull)` guards in navigation/router tests. 126 + - Defensive assertions are common: boundary-value checks in utility tests, null/empty/error-path repository tests, and `expect(tester.takeException(), isNull)` guards in navigation/router tests.
+76
docs/dev/routing.md
··· 1 + --- 2 + title: AppView Routing 3 + updated: 2026-05-07 4 + --- 5 + 6 + Lazurite can route AppView reads through Bluesky, Blacksky, or a validated 7 + custom provider. The selected provider controls `app.bsky.*` content routing 8 + and web-link resolution. It does not decide where the user's account 9 + authenticates; OAuth authority still comes from the account's PDS metadata. 10 + 11 + ## Provider Model 12 + 13 + Provider selection is persisted from login and settings. Built-in providers 14 + define a stable key, AppView service DID, public XRPC host, login entryway, and 15 + web base URL. Custom providers require validation before use. 16 + 17 + `AppViewRouter` in `lib/core/network/app_view_router.dart` is the runtime 18 + source of provider state. Repositories should ask it for headers, public 19 + endpoint URLs, auth entryway URLs, web-link resolution, and health results. 20 + Long-lived services should not cache provider fields independently. 21 + 22 + ## Request Policy 23 + 24 + `AppBskyRoutingPolicy` applies the request policy. Authenticated `app.bsky.*` 25 + requests route through the user's PDS with an explicit `atproto-proxy` header 26 + for the selected AppView. Signed-out public `app.bsky.*` reads call the 27 + selected provider's public host directly. `com.atproto.*` requests bypass 28 + AppView routing and resolve to the relevant repo or PDS. 29 + 30 + Provider switching requires a soft restart. The app persists the new provider, 31 + stops new requests, cancels in-flight work where possible, rebuilds dependency 32 + injection, and drops stale responses by routing epoch. This prevents mixed 33 + provider state across long-lived Cubits and repositories. 34 + 35 + ## Fallbacks 36 + 37 + Cross-provider fallback is opt-in and limited to read-only public endpoints. It 38 + can retry transient failures such as rate limits, server errors, timeouts, or 39 + DNS failure against another built-in provider. It must not retry writes across 40 + providers. 41 + 42 + Identity fallback through Slingshot and backlink enrichment through 43 + Constellation are separate settings and separate trust boundaries. Treat 44 + fallback data as recovery or enrichment, not as authority for writes. 45 + 46 + ## Health And Capability 47 + 48 + Provider health probes run at startup and through a manual settings action. 49 + Capability checks track specific endpoints such as profile reads, post-thread 50 + reads, trends, and trending topics. The router uses capability state to avoid 51 + blind retries against a provider that does not support a path. 52 + 53 + Logs should include provider, endpoint, fallback reason, and circuit-breaker 54 + state without including auth tokens or full payloads. 55 + 56 + ## Trending 57 + 58 + Trending is a route at `/trending`. `TrendingScreen` loads trending topics and 59 + trend metadata from the selected provider. `lib/features/feed/data/trending_join.dart` 60 + joins topic rows with trend rows by parsed link key first, then by normalized 61 + topic text. If multiple candidates match, the newest trend wins, with a stable 62 + link tie-breaker. 63 + 64 + The screen handles provider divergence. Bluesky and Blacksky can return 65 + different link formats and suggested topic sets. Unknown internal links fall 66 + back to provider-aware external URLs. If topic loading fails, the screen shows a 67 + blocking error. If topic loading succeeds but metadata fails, the screen renders 68 + usable topic rows with a non-blocking metadata warning. 69 + 70 + ## OAuth Boundary 71 + 72 + Selected AppView controls content reads. OAuth host selection starts with the 73 + account authority: resolve handle or DID, fetch protected-resource metadata, and 74 + prefer the advertised authorization server. Fallbacks can use the resolved PDS, 75 + then default entryways, but the selected AppView must not override account 76 + authority.
+72
docs/dev/semantic-search.md
··· 1 + --- 2 + title: Semantic Search 3 + updated: 2026-05-07 4 + --- 5 + 6 + Semantic search lets users search saved and liked posts by meaning while 7 + keeping all indexing and query work on device. Drift remains the source of truth 8 + for post content. ObjectBox stores embedding vectors and the metadata needed to 9 + join search results back to Drift rows. 10 + 11 + ## Storage Model 12 + 13 + ObjectBox is the secondary store for vectors. `EmbeddedPost` in 14 + `lib/core/objectbox/embedded_post.dart` records the post URI, active account 15 + DID, source (`saved` or `liked`), indexed text, vector, and embedding timestamp. 16 + The vector uses a 384-dimensional HNSW cosine index. 17 + 18 + All queries filter by account DID. Account switching must not query another 19 + account's vector rows. Removing a saved or liked post deletes the matching 20 + embedded row. 21 + 22 + Liked posts are cached in Drift so they can participate in local search. The 23 + liked-post table is account-scoped, keyed by post URI, and capped to keep 24 + storage bounded. Sync fetches recent likes until it reaches a known URI or the 25 + configured cap. 26 + 27 + ## Embedding Runtime 28 + 29 + `EmbeddingService` in `lib/core/embedding/embedding_service.dart` loads the 30 + bundled MiniLM INT8 TFLite model and WordPiece vocabulary in a long-lived 31 + isolate. The isolate keeps model work off the UI thread. Each request tokenizes 32 + text, pads or truncates to the model limit, runs inference, normalizes the 33 + vector, and returns it to the caller. 34 + 35 + Searchable text comes from the post text, image alt text, and link-card title 36 + or description. If the model or tokenizer cannot load, the service reports 37 + unavailable and UI entry points hide or explain semantic search rather than 38 + throwing. 39 + 40 + ## Indexing 41 + 42 + `SemanticIndexer` in `lib/features/search/data/semantic_indexer.dart` handles 43 + incremental indexing when a post is saved or synced as liked. The indexer 44 + extracts text, embeds it, and upserts the ObjectBox row. Backfill runs when the 45 + feature is enabled for an account or when the user requests reindexing. It 46 + processes posts in batches and reports progress for the settings UI. 47 + 48 + Posts on AT Protocol are immutable for this purpose, so embeddings do not need 49 + content refresh. Deletion paths still matter: unsave and unlike must remove 50 + vectors so search results match the user's visible saved and liked sets. 51 + 52 + ## Query Flow 53 + 54 + `SemanticSearchRepository` embeds the query with the same model, runs ObjectBox 55 + nearest-neighbor search, applies account and source filters, then hydrates full 56 + post views from Drift. Results are ordered by vector similarity and shown with a 57 + relevance indicator. 58 + 59 + The UI lives as a Search tab on the saved-posts screen. It debounces input, 60 + supports saved/liked/both scopes, reuses post cards, and exposes empty, 61 + unavailable, loading, and no-result states. Settings control feature enablement, 62 + default scope, indexed count, reindexing, and maximum result count. 63 + 64 + ## Operational Notes 65 + 66 + ObjectBox initialization runs once at startup after Drift. The generated 67 + ObjectBox model files must stay committed. Entity changes require code 68 + generation and a review of migration impact on existing vector data. 69 + 70 + The feature has clear limits: image meaning is available only through alt text, 71 + search is scoped to one account, results are not BM25 re-ranked, and very old 72 + likes may fall outside the local like cap.
+93
docs/dev/social-features-and-moderation.md
··· 1 + --- 2 + title: Social Features And Moderation 3 + updated: 2026-05-07 4 + --- 5 + 6 + Messaging, in-app media, multi-account behavior, offline rendering, moderation, lists, 7 + and starter packs extend the same repository, Cubit, and presentation patterns used by 8 + feeds and profiles. 9 + 10 + ## Direct Messages 11 + 12 + Direct messages use the `chat.bsky.*` namespace. `ConvoListBloc` in 13 + `lib/features/messages/bloc` manages conversation list state. 14 + `chat.bsky.convo.listConvos` loads conversations, and message threads paginate 15 + through `chat.bsky.convo.getMessages`. Sending uses `sendMessage`; starting a 16 + thread uses `getConvoForMembers`. 17 + 18 + The list separates primary conversations from requests. A request is a conversation 19 + where the active user has not sent a message. Threads render own messages on the 20 + trailing side and other messages on the leading side. Long press copies one message, 21 + while the conversation overflow can copy the full thread. 22 + 23 + ## Media 24 + 25 + Media screens live under `lib/features/feed/presentation/media`. Images open 26 + in an in-app full-screen viewer with paging, zoom, alt text, share, and 27 + download controls. Videos open in an in-app player that uses the embed HLS 28 + playlist, respects aspect ratio, disposes controllers on pop, and handles 29 + GIF-style looping playback. 30 + 31 + Downloads ask for media-library permission only when the user starts a save. 32 + Images use their full-size URL. Videos resolve the best available playlist 33 + variant before saving. The UI reports progress and surfaces permission or 34 + download failures through snackbars. 35 + 36 + ## Accounts And Offline State 37 + 38 + The active account DID is persisted in settings. Switching accounts updates the 39 + active DID, rebuilds account-scoped repositories and Cubits, and reloads data 40 + for the new identity. If refresh fails for the selected account, navigation 41 + returns to login for that account. 42 + 43 + Offline rendering depends on cached posts and profiles. Screens should show 44 + cached data first, fetch fresh data in the background, and keep rendering cache 45 + if the network call fails. Actions that require network access are disabled 46 + while offline with a clear explanation. 47 + 48 + ## Moderation 49 + 50 + `ModerationService` in `lib/features/moderation/data/moderation_service.dart` 51 + uses Bluesky labelers, user preferences, and the SDK moderation engine. The app 52 + builds moderation options from the active account's preferences and subscribed 53 + labelers, then runs posts, profiles, and notifications through the appropriate 54 + moderation helper before display. 55 + 56 + Rendering uses the moderation UI decision for the current context. Filtered 57 + content is removed from lists. Blurred content gets a click-through overlay 58 + unless the decision forbids override. Inform and alert labels render as badges. 59 + Avatar-specific decisions use placeholder avatars when needed. 60 + 61 + Subscribed labeler definitions are cached in Drift so preference screens and 62 + moderation decisions can use recent data when offline. The XRPC client includes 63 + the accepted-labelers header on content requests and updates that header when 64 + preferences change. 65 + 66 + ## Lists 67 + 68 + `ListRepository` in `lib/features/lists/data/list_repository.dart` manages AT 69 + Protocol graph list records. Curation lists provide feeds, moderation lists can 70 + be muted or blocked as a group, and reference lists back starter packs. List 71 + records, list items, and list blocks are created and deleted through 72 + `com.atproto.repo` operations. 73 + 74 + My Lists shows lists created by the active account. List detail screens show 75 + metadata, members, and for curation lists, a feed backed by 76 + `app.bsky.feed.getListFeed`. Member management uses actor typeahead, creates 77 + `app.bsky.graph.listitem` records, and deletes the corresponding item records 78 + when removing members. 79 + 80 + ## Starter Packs 81 + 82 + `StarterPackRepository` in `lib/features/starter_packs/data` manages starter 83 + packs. A starter pack points at a reference list for members and can include up 84 + to three feed generator URIs. 85 + 86 + Creating a starter pack first creates the reference list, then member list-item 87 + records, then the starter pack record. Editing members changes the backing 88 + reference list. Editing name, description, or feeds updates the starter pack 89 + record. 90 + 91 + Starter pack detail screens render creator, description, member sample, feed 92 + recommendations, and join counts. Actor profile surfaces can show starter packs 93 + created by that actor, and search can route directly to a pack detail screen.
+74
docs/dev/typeahead.md
··· 1 + --- 2 + title: Typeahead 3 + updated: 2026-05-07 4 + --- 5 + 6 + Typeahead is a shared actor autocomplete system used by login, search, 7 + jump-to-profile, list member management, and starter pack member management. It 8 + normalizes results from more than one backend so UI code can render one result 9 + model. 10 + 11 + ## Providers 12 + 13 + The official provider calls `app.bsky.actor.searchActorsTypeahead` through the 14 + Bluesky SDK. It requires an authenticated session and returns profile basics, 15 + including viewer data when available. 16 + 17 + The community provider calls the waow.tech compatible XRPC endpoint over HTTP. 18 + It does not require authentication, so login can offer suggestions before the 19 + user has a session. It returns a compatible actors array but omits viewer state. 20 + The app adds an `X-Client: lazurite` header and applies local moderation after 21 + parsing. 22 + 23 + Both providers normalize into the shared typeahead result model: DID, handle, 24 + optional display name, optional avatar URL, and labels. UI code should not 25 + branch on raw provider response shapes. 26 + 27 + ## Repository Behavior 28 + 29 + `TypeaheadRepository` in `lib/features/typeahead/data/typeahead_repository.dart` 30 + owns provider selection, HTTP calls, SDK calls, parsing, moderation filtering, 31 + and fallback. When the configured provider is official, it delegates to the SDK 32 + and includes moderation-aware request behavior. When the configured provider is 33 + community, it performs the HTTP request, parses JSON, and filters locally. 34 + 35 + If the community endpoint fails and an authenticated Bluesky client is 36 + available, the repository can fall back to the official endpoint. Login cannot 37 + use this fallback because there is no session yet. Fallbacks should be logged 38 + with provider and failure reason. 39 + 40 + ## Settings 41 + 42 + The selected provider is stored in settings as `typeahead_provider`. The 43 + default is the official Bluesky provider. Settings expose both official and 44 + community options, with copy that makes the third-party nature of the community 45 + provider clear. 46 + 47 + Login overrides the setting and uses the community provider because official 48 + typeahead requires auth. All authenticated surfaces respect the saved setting. 49 + 50 + ## UI And State 51 + 52 + `TypeaheadCubit` owns query changes, debounce, loading, results, and error 53 + state. Consumers should use the shared Cubit or repository instead of calling 54 + search repositories directly for actor autocomplete. 55 + 56 + `TypeaheadTextField` in `lib/features/typeahead/presentation/typeahead_text_field.dart` 57 + anchors suggestions below a text field with an overlay. It debounces input, 58 + ignores empty or too-short queries, and updates overlay position with keyboard 59 + and layout changes. Selecting a result fills the field and calls the consumer's 60 + selection callback. 61 + 62 + The search screen should keep post search state separate from actor typeahead. 63 + Jump-to-profile, list member add, and starter pack member add use the same 64 + autocomplete path so moderation, rate limiting, and provider choice remain 65 + consistent. 66 + 67 + ## Limits 68 + 69 + Debounce defaults to 300 ms and empty queries return without network calls. 70 + In-flight requests should be canceled or ignored when a newer query starts. 71 + 72 + Community responses do not include viewer state, so follow badges or other 73 + viewer-dependent affordances should hide rather than guess. DID entry bypasses 74 + typeahead because typeahead is handle and display-name search.
docs/images/about.png

This is a binary file and will not be displayed.

docs/images/dev-tools.png

This is a binary file and will not be displayed.

docs/images/home-feed.png

This is a binary file and will not be displayed.

docs/images/profile.png

This is a binary file and will not be displayed.

docs/images/search.png

This is a binary file and will not be displayed.

-241
docs/specs/animate.md
··· 1 - --- 2 - title: Micro-Animations Spec 3 - updated: 2026-04-27 4 - --- 5 - 6 - ## Summary 7 - 8 - Add polished micro-animations across the app using `flutter_animate` to bring 9 - life to transitions, state changes, and user interactions. The goal is subtle, 10 - fast, purposeful motion — not decorative. Every animation must serve 11 - orientation ("where am I?"), feedback ("did that work?"), or continuity 12 - ("what just changed?"). 13 - 14 - ## Package 15 - 16 - **`flutter_animate`** (pub.dev) — declarative, composable animation chains 17 - via extension methods on `Widget`. Chosen over raw `AnimationController` / 18 - `ImplicitlyAnimatedWidget` for consistency and velocity: a single API for 19 - fade, slide, scale, blur, shimmer, and custom effects, with built-in 20 - stagger support. 21 - 22 - Add to `pubspec.yaml`: 23 - 24 - ```yaml 25 - dependencies: 26 - flutter_animate: ^4.5.2 27 - ``` 28 - 29 - ## Animation Inventory 30 - 31 - ### 1. Feed & List Items — Staggered Entrance 32 - 33 - **Where:** Post cards in feed, notification rows, search results, follow 34 - audit results, list members, saved posts. 35 - 36 - **Effect:** Each item fades in + slides up as it enters the viewport for the 37 - first time (initial load or pagination append). 38 - 39 - ```dart 40 - child 41 - .animate() 42 - .fadeIn(duration: 200.ms, curve: Curves.easeOut) 43 - .slideY(begin: 0.05, end: 0, duration: 200.ms, curve: Curves.easeOut) 44 - ``` 45 - 46 - Stagger: 50ms offset per item (capped at 10 items per batch to avoid long 47 - entrance sequences on large pages). 48 - 49 - **Constraint:** Only on first appearance. Scrolling back to an already-seen 50 - item must not re-animate. Track via a `Set<String>` of post URIs (or item 51 - keys) in the feed cubit/bloc. 52 - 53 - ### 2. Action Feedback — Like, Repost, Bookmark 54 - 55 - **Where:** Post action bar icons. 56 - 57 - **Effect:** On tap, the icon scales up briefly then settles back, with a 58 - color crossfade to the active tint. 59 - 60 - ```dart 61 - icon 62 - .animate(onPlay: (c) => c.forward()) 63 - .scale(begin: 1.0, end: 1.3, duration: 120.ms, curve: Curves.easeOut) 64 - .then() 65 - .scale(begin: 1.3, end: 1.0, duration: 100.ms, curve: Curves.easeOutBack) 66 - ``` 67 - 68 - The color change uses `AnimatedSwitcher` or `ColorTween` on the existing 69 - icon — `flutter_animate`'s `.tint()` effect is an alternative. 70 - 71 - ### 3. Screen Transitions — Fade-Through 72 - 73 - **Where:** All `GoRouter` page transitions (currently using default Material 74 - platform transitions). 75 - 76 - **Effect:** Material fade-through (outgoing screen fades out + scales down 77 - slightly, incoming screen fades in + scales up slightly). Duration 300ms. 78 - 79 - Implement via a custom `TransitionPage` wrapper: 80 - 81 - ```dart 82 - class FadeThroughPage<T> extends CustomTransitionPage<T> { 83 - FadeThroughPage({required super.child, super.key}) 84 - : super( 85 - transitionsBuilder: (context, animation, secondaryAnimation, child) { 86 - return FadeThroughTransition( 87 - animation: animation, 88 - secondaryAnimation: secondaryAnimation, 89 - child: child, 90 - ); 91 - }, 92 - transitionDuration: const Duration(milliseconds: 300), 93 - ); 94 - } 95 - ``` 96 - 97 - Use `animations` package's `FadeThroughTransition` or implement manually 98 - with `flutter_animate` chained fade + scale. 99 - 100 - ### 4. Skeleton / Shimmer Loading 101 - 102 - **Where:** Feed loading placeholders, profile header loading, notification 103 - list loading — anywhere a shimmer placeholder is shown. 104 - 105 - **Effect:** Replace static grey boxes with a shimmer sweep using 106 - `.shimmer(duration: 1200.ms, color: theme.colorScheme.surfaceContainerHigh)`. 107 - 108 - ```dart 109 - Container( 110 - height: 16, width: 120, 111 - decoration: BoxDecoration( 112 - color: theme.colorScheme.surfaceContainerHighest, 113 - borderRadius: BorderRadius.zero, // square geometry per UI refactor 114 - ), 115 - ) 116 - .animate(onPlay: (c) => c.repeat()) 117 - .shimmer(duration: 1200.ms, color: theme.colorScheme.surfaceContainerHigh) 118 - ``` 119 - 120 - ### 5. Bottom Navigation Bar — Icon Transition 121 - 122 - **Where:** Bottom nav bar active/inactive icon swap. 123 - 124 - **Effect:** Active icon scales up from 1.0 to 1.15 with a fade crossfade 125 - between outlined → filled icon variants. Duration 150ms. 126 - 127 - ### 6. Snackbar / Toast Entrance 128 - 129 - **Where:** All snackbar and toast messages. 130 - 131 - **Effect:** Slide up from bottom + fade in (200ms). On dismiss, fade out + 132 - slide down (150ms). 133 - 134 - ### 7. FAB / Action Button 135 - 136 - **Where:** Compose FAB, gallery FAB, scroll-to-top button. 137 - 138 - **Effect:** Scale-in on appear (`scaleXY` 0 → 1, 200ms, `Curves.easeOutBack`). 139 - Scale-out on disappear (reverse). 140 - 141 - ### 8. Pull-to-Refresh Indicator 142 - 143 - **Where:** Feed and list pull-to-refresh. 144 - 145 - **Effect:** The refresh indicator rotates continuously during refresh, then 146 - scales down + fades out on completion (200ms). 147 - 148 - ### 9. Profile Header — Parallax 149 - 150 - **Where:** Profile screen banner image. 151 - 152 - **Effect:** Subtle parallax on scroll — banner moves at 0.5x scroll speed. 153 - Implemented via `SliverAppBar`'s existing `flexibleSpace` with a 154 - `Transform.translate` driven by scroll offset, not `flutter_animate`. 155 - 156 - ### 10. Empty State Illustrations 157 - 158 - **Where:** Empty feeds, no search results, no notifications. 159 - 160 - **Effect:** Fade in + gentle scale from 0.95 to 1.0 (300ms, ease-out). 161 - Prevents the "flash" of empty state content. 162 - 163 - ## Animation Tokens 164 - 165 - Centralise timing and curves in a single file to keep motion consistent: 166 - 167 - **`lib/core/theme/animation_tokens.dart`** 168 - 169 - ```dart 170 - abstract final class Anim { 171 - // Durations 172 - static const fast = Duration(milliseconds: 150); 173 - static const normal = Duration(milliseconds: 250); 174 - static const slow = Duration(milliseconds: 400); 175 - 176 - // Curves 177 - static const enter = Curves.easeOut; 178 - static const exit = Curves.easeIn; 179 - static const emphasis = Curves.easeOutBack; 180 - 181 - // Stagger 182 - static const staggerOffset = Duration(milliseconds: 50); 183 - static const maxStaggerItems = 10; 184 - } 185 - ``` 186 - 187 - All animations in the codebase reference these tokens — no magic numbers 188 - in widget files. 189 - 190 - ## Reduced Motion 191 - 192 - Respect the platform's "reduce motion" accessibility setting: 193 - 194 - ```dart 195 - final reduceMotion = MediaQuery.of(context).disableAnimations; 196 - ``` 197 - 198 - When `reduceMotion` is true, skip all non-essential animations. Essential 199 - transitions (screen changes) use a simple crossfade at 150ms. Loading 200 - shimmers continue (they convey information, not decoration). 201 - 202 - Wrap in a utility: 203 - 204 - ```dart 205 - extension AnimateAccessibility on Widget { 206 - Widget animateIfAllowed(BuildContext context, List<Effect> effects) { 207 - if (MediaQuery.of(context).disableAnimations) return this; 208 - return animate(effects: effects); 209 - } 210 - } 211 - ``` 212 - 213 - ## Performance 214 - 215 - - All animations use `flutter_animate`'s transform-based effects (GPU 216 - composited) — avoid layout-triggering properties like width/height 217 - animation on list items. 218 - - Stagger caps at 10 items to avoid jank on low-end devices. 219 - - Profile the animation layer with Flutter DevTools' performance overlay 220 - before merge — target 0 skipped frames on a mid-range Android device 221 - (Pixel 6a equivalent). 222 - - Feed item animations are fire-once; re-scrolling does not re-trigger. 223 - 224 - ## Testing 225 - 226 - - Unit test `Anim` token values (sanity check, constants don't drift). 227 - - Widget tests for animated widgets use `tester.pumpAndSettle()` to 228 - complete animations, then assert final visual state. 229 - - Widget tests for reduced-motion: set `MediaQuery.disableAnimations` 230 - to `true`, verify no `Animate` widget in the tree. 231 - - Golden tests for shimmer placeholders (capture mid-animation frame). 232 - 233 - ## Scope & Constraints 234 - 235 - - **No animated illustrations or Lottie.** All motion is CSS-style 236 - property animation — no custom vector art or frame-based animation. 237 - - **No animation preferences screen.** We respect the OS-level reduced 238 - motion toggle only. A per-app toggle is out of scope. 239 - - **No physics-based animations** (springs, flings) in this pass. 240 - `flutter_animate` supports them but they're harder to keep consistent. 241 - Evaluate in a future milestone.
-330
docs/specs/phase-1.md
··· 1 - # Phase 1 2 - 3 - BLoC for state management via `flutter_bloc`. Each feature gets its own 4 - Bloc/Cubit—keep them small and focused. Use `BlocProvider` for DI, 5 - `BlocBuilder` / `BlocSelector` for granular rebuilds, and `BlocListener` for 6 - one-shot side effects (navigation, snackbars). All state classes must be 7 - immutable with `copyWith()`. Use `HydratedBloc` where session persistence is 8 - needed (e.g. theme preference). 9 - 10 - Drift (formerly Moor) for SQLite persistence. Type-safe, reactive (stream-based 11 - queries auto-update the UI), compile-time checked SQL, and built-in migration 12 - support. Tables: `accounts` (DID, handle, tokens), `cached_profiles`, 13 - `cached_posts`, `settings`. 14 - 15 - Feature-first folder structure: 16 - 17 - ```sh 18 - lib/ 19 - core/ # shared models, theme, routing, DI 20 - features/ 21 - auth/ # bloc, data, presentation 22 - profile/ # bloc, data, presentation 23 - settings/ # bloc, data, presentation 24 - ``` 25 - 26 - ## Packages 27 - 28 - | Package | Purpose | 29 - | ---------------- | ------------------------------------------------- | 30 - | `bluesky` | Full AT Protocol + `app.bsky.*` / `chat.bsky.*` | 31 - | `atproto_oauth` | AT Protocol OAuth 2.0 for Flutter | 32 - | `bluesky_text` | Rich text / facet parsing | 33 - | `flutter_bloc` | BLoC / Cubit state management | 34 - | `drift` | Type-safe reactive SQLite ORM | 35 - | `go_router` | Declarative routing | 36 - 37 - ## Authentication 38 - 39 - ### 1. OAuth 2.0 (Production) 40 - 41 - AT Protocol mandates **DPoP + PAR + PKCE** for all clients. Lazurite is a 42 - public native client (`token_endpoint_auth_method: "none"`, 43 - `application_type: "native"`). 44 - 45 - **Constants:** 46 - 47 - ```dart 48 - static const kClientId = 'https://lazurite.stormlightlabs.org/client-metadata.json'; 49 - static const kRedirectUri = 'http://127.0.0.1/callback'; 50 - static const kScope = 'atproto transition:generic'; 51 - ``` 52 - 53 - Client metadata is hosted at `https://lazurite.stormlightlabs.org/client-metadata.json`. 54 - The PDS fetches this document to verify the client during the OAuth flow. 55 - 56 - **Flow:** 57 - 58 - 1. Generate a DPoP keypair (store private key in platform keychain; non-exportable). 59 - 2. Generate PKCE `code_verifier` → `code_challenge`. 60 - 3. POST to the PDS **PAR endpoint** with `kClientId`, `code_challenge`, 61 - `kScope`, and an initial DPoP Proof JWT → receive a `request_uri`. 62 - 4. Open system browser / `ASWebAuthenticationSession` / 63 - `CustomTabsIntent` to the PDS **authorization endpoint** with the 64 - `request_uri`. 65 - 5. User authenticates on their PDS and grants consent. 66 - 6. PDS redirects to the **loopback redirect** (`http://127.0.0.1/callback`) 67 - with an authorization `code`. The app starts a temporary local HTTP server 68 - to capture the callback. 69 - 7. Exchange `code` + `code_verifier` + new DPoP Proof JWT at the **token 70 - endpoint** → receive DPoP-bound `access_token` + `refresh_token`. 71 - 8. Every API request sends the `access_token` in `Authorization` and a fresh 72 - DPoP Proof JWT in the `DPoP` header. 73 - 74 - Token lifetimes: `access_token` ~2 h, `refresh_token` ~2 months. The 75 - `atproto_oauth` package handles automatic refresh. 76 - 77 - ### 2. App Password (Debug Only) 78 - 79 - Calls `com.atproto.server.createSession` with handle + app password 80 - (`xxxx-xxxx-xxxx-xxxx`). Returns `accessJwt` / `refreshJwt` + DID / handle. 81 - Rate limit: 30 req / 5 min, 300 / day. 82 - 83 - App passwords cannot delete or migrate the account, nor create other app 84 - passwords. Guard this path behind a compile-time debug flag 85 - (`kDebugMode` / `--dart-define`). 86 - 87 - ### 3. Login & Logout 88 - 89 - - **Login screen:** handle input + "Sign in with BlueSky" button (OAuth) and, 90 - in debug builds, an app-password form. 91 - - **Session restore:** on launch, read stored tokens from Drift → attempt 92 - silent refresh → land on home or login. 93 - - **Logout:** revoke tokens, wipe Drift session row, clear in-memory Bloc 94 - state, navigate to login. 95 - - **Multi-account (stretch):** `accounts` table supports multiple DIDs; account 96 - switcher in settings. 97 - 98 - ## Profile Rendering 99 - 100 - Data source: `app.bsky.actor.getProfile` (single) / 101 - `app.bsky.actor.getProfiles` (batch). 102 - 103 - **Profile fields to render:** 104 - 105 - - Avatar + banner images (CDN URIs) 106 - - `displayName`, `handle`, `description` 107 - - Follower / following / post counts 108 - - `pronouns`, `website` 109 - 110 - ### 1. Posts 111 - 112 - Fetch via `app.bsky.feed.getAuthorFeed`. Paginate with `cursor`; support 113 - `filter` values: `posts_no_replies`, `posts_with_media`, 114 - `posts_and_author_threads`. 115 - 116 - Each feed item is an `app.bsky.feed.defs#feedViewPost` containing a `post` 117 - view. Key fields: `text`, `createdAt`, `embed` (images ≤ 4, quote posts, 118 - external link cards, video), `reply` parent/root refs, `langs`. 119 - 120 - ### 2. Post Facets (Rich Text) 121 - 122 - Facets annotate byte ranges of the UTF-8 `text` field. Each facet has an 123 - `index` (`byteStart` inclusive, `byteEnd` exclusive) and a `features` array. 124 - 125 - | Feature type | Payload | 126 - | ---------------------------------- | --------- | 127 - | `app.bsky.richtext.facet#mention` | `did` | 128 - | `app.bsky.richtext.facet#link` | `uri` | 129 - | `app.bsky.richtext.facet#tag` | `tag` | 130 - 131 - Use the `bluesky_text` package to parse facets. Render mentions as tappable 132 - profile links, URIs as tappable external links, and hashtags as tappable search 133 - links. Byte indices are **UTF-8**—do not use Dart's UTF-16 string indices 134 - directly. 135 - 136 - ## Settings 137 - 138 - ### 1. Light / Dark Mode 139 - 140 - System default, Light, and Dark. Persist choice in Drift `settings` table. 141 - Apply via `ThemeMode` on `MaterialApp`. Use `HydratedBloc` or a `SettingsCubit` 142 - that reads/writes the preference. 143 - 144 - ### 2. Custom Themes 145 - 146 - Ship built-in theme palettes. Each theme provides a dark and light variant. 147 - Persist the user's theme choice in the Drift `settings` table. Expose each 148 - via factory constructors (e.g. `CatppuccinTheme.dark()`). 149 - 150 - #### Oxocarbon (IBM Carbon inspired) 151 - 152 - **Dark**: 153 - 154 - | Token | Hex | Role | 155 - | -------- | --------- | --------------------------- | 156 - | base00 | `#161616` | Background | 157 - | base01 | `#262626` | Surface / card | 158 - | base02 | `#393939` | Selection / divider | 159 - | base03 | `#525252` | Muted text | 160 - | base04 | `#dde1e6` | Secondary text | 161 - | base05 | `#f2f4f8` | Primary text | 162 - | base06 | `#ffffff` | Bright text | 163 - | base07 | `#08bdba` | Teal accent | 164 - | base08 | `#3ddbd9` | Cyan highlight | 165 - | base09 | `#78a9ff` | Blue accent | 166 - | base0A | `#ee5396` | Pink / error | 167 - | base0B | `#33b1ff` | Light blue | 168 - | base0C | `#ff7eb6` | Magenta | 169 - | base0D | `#42be65` | Green / success | 170 - | base0E | `#be95ff` | Purple accent | 171 - | base0F | `#82cfff` | Sky blue | 172 - 173 - **Light**: 174 - 175 - | Token | Hex | Role | 176 - | -------- | --------- | --------------------------- | 177 - | base00 | `#ffffff` | Background | 178 - | base01 | `#f2f2f2` | Surface / card | 179 - | base02 | `#d0d0d0` | Selection / divider | 180 - | base03 | `#161616` | Primary text | 181 - | base04 | `#37474F` | Secondary text | 182 - | base05 | `#90A4AE` | Muted text | 183 - | base06 | `#525252` | Subheading text | 184 - | base07 | `#08bdba` | Teal accent | 185 - | base08 | `#ff7eb6` | Pink accent | 186 - | base09 | `#ee5396` | Error | 187 - | base0A | `#FF6F00` | Orange / warning | 188 - | base0B | `#0f62fe` | Primary blue | 189 - | base0C | `#673AB7` | Purple accent | 190 - | base0D | `#42be65` | Green / success | 191 - | base0E | `#be95ff` | Lavender accent | 192 - | base0F | `#FFAB91` | Salmon | 193 - 194 - Map these tokens to a `ThemeData` / `ColorScheme` and expose a 195 - `OxocarbonTheme.dark()` / `OxocarbonTheme.light()` factory. 196 - 197 - #### Catppuccin 198 - 199 - A community-driven pastel theme. Use **Mocha** (dark) and **Latte** (light). 200 - 201 - **Mocha (Dark)**: 202 - 203 - | Token | Hex | Role | 204 - | --------- | --------- | --------------------------- | 205 - | base | `#1e1e2e` | Background | 206 - | mantle | `#181825` | Surface / card | 207 - | surface0 | `#313244` | Selection / divider | 208 - | surface1 | `#45475a` | Muted text | 209 - | subtext0 | `#a6adc8` | Secondary text | 210 - | text | `#cdd6f4` | Primary text | 211 - | lavender | `#b4befe` | Primary accent | 212 - | blue | `#89b4fa` | Blue accent | 213 - | sapphire | `#74c7ec` | Cyan highlight | 214 - | green | `#a6e3a1` | Green / success | 215 - | red | `#f38ba8` | Red / error | 216 - | peach | `#fab387` | Orange / warning | 217 - | mauve | `#cba6f7` | Purple accent | 218 - | pink | `#f5c2e7` | Pink accent | 219 - | rosewater | `#f5e0dc` | Warm highlight | 220 - 221 - **Latte (Light)**: 222 - 223 - | Token | Hex | Role | 224 - | --------- | --------- | --------------------------- | 225 - | base | `#eff1f5` | Background | 226 - | mantle | `#e6e9ef` | Surface / card | 227 - | surface0 | `#ccd0da` | Selection / divider | 228 - | surface1 | `#bcc0cc` | Muted text | 229 - | subtext0 | `#6c6f85` | Secondary text | 230 - | text | `#4c4f69` | Primary text | 231 - | lavender | `#7287fd` | Primary accent | 232 - | blue | `#1e66f5` | Blue accent | 233 - | sapphire | `#209fb5` | Cyan highlight | 234 - | green | `#40a02b` | Green / success | 235 - | red | `#d20f39` | Red / error | 236 - | peach | `#fe640b` | Orange / warning | 237 - | mauve | `#8839ef` | Purple accent | 238 - | pink | `#ea76cb` | Pink accent | 239 - | rosewater | `#dc8a78` | Warm highlight | 240 - 241 - Map to `CatppuccinTheme.dark()` / `CatppuccinTheme.light()`. 242 - 243 - #### Nord 244 - 245 - An arctic, north-bluish palette inspired by the polar night and aurora 246 - borealis. 247 - 248 - **Polar Night (Dark)**: 249 - 250 - | Token | Hex | Role | 251 - | ------- | --------- | --------------------------- | 252 - | nord0 | `#2e3440` | Background | 253 - | nord1 | `#3b4252` | Surface / card | 254 - | nord2 | `#434c5e` | Selection / divider | 255 - | nord3 | `#4c566a` | Muted text | 256 - | nord4 | `#d8dee9` | Secondary text | 257 - | nord5 | `#e5e9f0` | Primary text | 258 - | nord6 | `#eceff4` | Bright text | 259 - | nord7 | `#8fbcbb` | Teal accent | 260 - | nord8 | `#88c0d0` | Cyan / primary accent | 261 - | nord9 | `#81a1c1` | Blue accent | 262 - | nord10 | `#5e81ac` | Deep blue | 263 - | nord11 | `#bf616a` | Red / error | 264 - | nord12 | `#d08770` | Orange / warning | 265 - | nord13 | `#ebcb8b` | Yellow | 266 - | nord14 | `#a3be8c` | Green / success | 267 - | nord15 | `#b48ead` | Purple accent | 268 - 269 - **Snow Storm (Light)**: 270 - 271 - | Token | Hex | Role | 272 - | ------- | --------- | --------------------------- | 273 - | nord0 | `#eceff4` | Background | 274 - | nord1 | `#e5e9f0` | Surface / card | 275 - | nord2 | `#d8dee9` | Selection / divider | 276 - | nord3 | `#4c566a` | Primary text | 277 - | nord4 | `#434c5e` | Secondary text | 278 - | nord5 | `#3b4252` | Subheading text | 279 - | nord6 | `#2e3440` | Bright / heading text | 280 - | nord7 | `#8fbcbb` | Teal accent | 281 - | nord8 | `#88c0d0` | Cyan / primary accent | 282 - | nord9 | `#81a1c1` | Blue accent | 283 - | nord10 | `#5e81ac` | Deep blue | 284 - | nord11 | `#bf616a` | Red / error | 285 - | nord12 | `#d08770` | Orange / warning | 286 - | nord13 | `#ebcb8b` | Yellow | 287 - | nord14 | `#a3be8c` | Green / success | 288 - | nord15 | `#b48ead` | Purple accent | 289 - 290 - Map to `NordTheme.dark()` / `NordTheme.light()`. 291 - 292 - #### Rosé Pine 293 - 294 - An all-natural pine theme with muted, elegant tones. 295 - 296 - **Main (Dark)**: 297 - 298 - | Token | Hex | Role | 299 - | -------------- | --------- | --------------------------- | 300 - | base | `#191724` | Background | 301 - | surface | `#1f1d2e` | Surface / card | 302 - | overlay | `#26233a` | Selection / divider | 303 - | muted | `#6e6a86` | Muted text | 304 - | subtle | `#908caa` | Secondary text | 305 - | text | `#e0def4` | Primary text | 306 - | love | `#eb6f92` | Red / error | 307 - | gold | `#f6c177` | Yellow / warning | 308 - | rose | `#ebbcba` | Rose accent (primary) | 309 - | pine | `#31748f` | Teal / deep accent | 310 - | foam | `#9ccfd8` | Cyan highlight | 311 - | iris | `#c4a7e7` | Purple accent | 312 - 313 - **Dawn (Light)**: 314 - 315 - | Token | Hex | Role | 316 - | -------------- | --------- | --------------------------- | 317 - | base | `#faf4ed` | Background | 318 - | surface | `#fffaf3` | Surface / card | 319 - | overlay | `#f2e9e1` | Selection / divider | 320 - | muted | `#9893a5` | Muted text | 321 - | subtle | `#797593` | Secondary text | 322 - | text | `#575279` | Primary text | 323 - | love | `#b4637a` | Red / error | 324 - | gold | `#ea9d34` | Yellow / warning | 325 - | rose | `#d7827e` | Rose accent (primary) | 326 - | pine | `#286983` | Teal / deep accent | 327 - | foam | `#56949f` | Cyan highlight | 328 - | iris | `#907aa9` | Purple accent | 329 - 330 - Map to `RosePineTheme.dark()` / `RosePineTheme.light()`.
-217
docs/specs/phase-2.md
··· 1 - # Lazurite Phase 2 Spec 2 - 3 - ## Logging 4 - 5 - Structured logging for development debugging and in-app log inspection. Uses 6 - the [`logger`](https://pub.dev/packages/logger) package (v2.x) — the most 7 - widely adopted Flutter logging library — with file-based persistence via its 8 - built-in `AdvancedFileOutput`. 9 - 10 - ### Log Levels 11 - 12 - | Level | Usage | 13 - | --------- | ------------------------------------------- | 14 - | `trace` | Fine-grained control flow (loop iterations) | 15 - | `debug` | Development-only diagnostics | 16 - | `info` | Significant lifecycle events (login, nav) | 17 - | `warning` | Recoverable issues (retry, fallback) | 18 - | `error` | Failures with stack traces | 19 - | `fatal` | Unrecoverable errors (crash-level) | 20 - 21 - ### Architecture 22 - 23 - A single `AppLogger` wrapper class exposes a top-level `log` instance injected 24 - via the service locator. All subsystems log through this instance. 25 - 26 - ```sh 27 - AppLogger 28 - ├── LogFilter (DevelopmentFilter / ProductionFilter) 29 - ├── LogPrinter (PrettyPrinter for dev, SimplePrinter for file) 30 - └── LogOutput 31 - ├── ConsoleOutput (always, dev only) 32 - └── AdvancedFileOutput (always, all builds) 33 - ``` 34 - 35 - **Console output** — enabled only in debug builds via `DevelopmentFilter`. 36 - Uses `PrettyPrinter` with method counts and colors for readability 37 - in the terminal. 38 - 39 - **File output** — enabled in all builds. Uses `AdvancedFileOutput` which 40 - writes to the app's documents directory (`getApplicationDocumentsDirectory()`). 41 - Files are rotated daily with a configurable retention window (default 3 days). 42 - File format: `lazurite_YYYY-MM-DD.log`, one line per event using 43 - `SimplePrinter(colors: false)`. 44 - 45 - ### Integration Points 46 - 47 - | Subsystem | What gets logged | 48 - | --------- | ----------------------------------------------- | 49 - | BLoC | State transitions via `BlocObserver` override | 50 - | HTTP | Request/response summaries (no auth headers) | 51 - | Auth | OAuth flow steps, token refresh, session events | 52 - | Nav | Route changes via `NavigatorObserver` | 53 - | DB | Drift query errors | 54 - 55 - **Security:** Never log access tokens, refresh tokens, passwords, or full 56 - request/response bodies. HTTP logging redacts the `Authorization` header and 57 - truncates bodies to 200 chars. 58 - 59 - ### In-App Log Viewer 60 - 61 - Accessible via Settings → Dev Tools → Logs. Reads log files from disk and 62 - displays entries in a scrollable, filterable list. 63 - 64 - **Features:** 65 - 66 - 1. **Level filter** — chip bar to toggle visibility per level 67 - 2. **Search** — free-text filter across log messages 68 - 3. **Auto-scroll** — locks to bottom for live tailing; unlocks on manual scroll 69 - 4. **Share** — export current day's log file via the system share sheet 70 - 5. **Clear** — delete all log files with confirmation 71 - 72 - Each log entry renders as a single row: 73 - 74 - | Element | Format | 75 - | --------- | ----------------------------------- | 76 - | Timestamp | `HH:mm:ss.SSS` in monospace | 77 - | Level | Colored badge (E / W / I / D) | 78 - | Message | Truncated to 2 lines, tap to expand | 79 - 80 - No Bloc needed — use a `LogViewerCubit` with simple file-read state, since 81 - this is a stateless inspection tool. 82 - 83 - ## Feeds 84 - 85 - Phase 1 builds profile author feeds only. Phase 2 adds the full home feed 86 - experience: the user's timeline, custom feed generators, and feed management. 87 - 88 - ### Timeline 89 - 90 - `app.bsky.feed.getTimeline` — reverse-chronological feed of posts from 91 - followed accounts. Paginate with `cursor`; `limit` 1–100 (default 50). 92 - 93 - ### Feed Generators 94 - 95 - Feed generators are third-party algorithmic feeds identified by AT-URIs 96 - (e.g. `at://did:plc:…/app.bsky.feed.generator/whats-hot`). 97 - 98 - | Endpoint | Purpose | 99 - | --------------------------------- | -------------------------------------- | 100 - | `app.bsky.feed.getFeed` | Fetch hydrated posts from a generator | 101 - | `app.bsky.feed.getFeedGenerator` | Metadata for a single generator | 102 - | `app.bsky.feed.getFeedGenerators` | Batch metadata for multiple generators | 103 - | `app.bsky.feed.getSuggestedFeeds` | Discover new feed generators | 104 - 105 - `getFeed` takes the generator's AT-URI + `cursor` / `limit`. The AppView 106 - resolves posts returned by the generator and hydrates them into full 107 - `feedViewPost` views. 108 - 109 - ### Rendering 110 - 111 - Each feed renders as the same post-card list built in Phase 1. The home screen 112 - uses a horizontally-swipable tab bar — one tab per pinned feed, with 113 - "Following" (timeline) as the default. 114 - 115 - ### Feed Management 116 - 117 - User feed preferences are stored server-side via 118 - `app.bsky.actor.putPreferences` / `getPreferences`. The preferences object 119 - contains a `savedFeedsPrefV2` array, where each entry has: 120 - 121 - - `id` — unique client-generated identifier 122 - - `type` — `feed` (generator) or `timeline` or `list` 123 - - `value` — AT-URI of the feed generator (or `timeline` literal) 124 - - `pinned` — whether the feed appears as a home tab 125 - 126 - **Operations:** 127 - 128 - | Action | Implementation | 129 - | ----------- | -------------------------------------------------------- | 130 - | Pin / Unpin | Toggle `pinned` flag, call `putPreferences` | 131 - | Reorder | Drag-to-reorder in settings, update array order, persist | 132 - | Remove | Remove entry from `savedFeedsPrefV2`, persist | 133 - | Add | Browse `getSuggestedFeeds`, append entry, persist | 134 - 135 - Build a `FeedPreferencesCubit` that reads on launch and writes back on 136 - mutation. Cache the preferences array in Drift for offline access. 137 - 138 - ## Search 139 - 140 - ### Search Posts 141 - 142 - `app.bsky.feed.searchPosts` — full-text search across the network. 143 - 144 - | Parameter | Description | 145 - | ---------- | -------------------------------------------- | 146 - | `q` | Query string (Lucene syntax supported) | 147 - | `sort` | `top` or `latest` | 148 - | `author` | Filter to a specific account (at-identifier) | 149 - | `mentions` | Filter to posts mentioning an account | 150 - | `lang` | BCP-47 language filter | 151 - | `since` | Posts after this datetime | 152 - | `until` | Posts before this datetime | 153 - | `tag` | Hashtag filter (without `#`; multiple = AND) | 154 - | `domain` | Posts containing links to this domain | 155 - | `url` | Posts containing this exact URL | 156 - 157 - Returns paginated `postView` objects. `limit` 1–100. 158 - 159 - ### Search Actors 160 - 161 - | Endpoint | Purpose | 162 - | -------------------------------------- | ------------------------------- | 163 - | `app.bsky.actor.searchActors` | Full profile search by query | 164 - | `app.bsky.actor.searchActorsTypeahead` | Prefix autocomplete for handles | 165 - 166 - `searchActors` returns `profileView` objects, paginated (`limit` 1–100). 167 - `searchActorsTypeahead` is lightweight, intended for real-time autocomplete 168 - in the search bar. 169 - 170 - ### Persisted Search History 171 - 172 - Store recent queries in a Drift `search_history` table: 173 - 174 - | Column | Type | Notes | 175 - | ------------- | -------- | -------------------------- | 176 - | `id` | integer | PK autoincrement | 177 - | `query` | text | The search string | 178 - | `type` | text | `posts` or `actors` | 179 - | `searched_at` | datetime | Timestamp | 180 - | `account_did` | text | FK to `accounts`, per-user | 181 - 182 - Display recent searches below the search bar. Tap to re-execute; swipe to 183 - delete. Cap at 50 entries per account, evicting oldest on insert. 184 - 185 - Build a `SearchBloc` with events: `QuerySubmitted`, `TypeaheadRequested`, 186 - `HistoryCleared`, `HistoryEntryDeleted`. 187 - 188 - ## Dev Tools 189 - 190 - ### PDS Explorer (pdsls.dev replica) 191 - 192 - An in-app developer tool accessible via Settings that replicates the core 193 - functionality of [pdsls.dev](https://pdsls.dev) — a client-side AT Protocol 194 - repository browser. 195 - 196 - **Core features:** 197 - 198 - 1. **Handle / DID resolution** — enter a handle, resolve to DID via 199 - `com.atproto.identity.resolveHandle`. 200 - 2. **Repository overview** — call `com.atproto.repo.describeRepo` to list all 201 - collections (NSIDs) in a user's repo with record counts. 202 - 3. **Collection browser** — select a collection, paginate through records via 203 - `com.atproto.repo.listRecords` (`limit`, `cursor`, `reverse`). 204 - 4. **Record inspector** — tap a record to view the full JSON via 205 - `com.atproto.repo.getRecord`. Pretty-print with syntax highlighting. 206 - 5. **AT-URI input** — paste an `at://` URI to jump directly to a record. 207 - 208 - | API Endpoint | Usage | 209 - | ------------------------------------ | ------------------------------------ | 210 - | `com.atproto.identity.resolveHandle` | Handle → DID | 211 - | `com.atproto.repo.describeRepo` | DID/handle → collection list | 212 - | `com.atproto.repo.listRecords` | Collection → paginated record list | 213 - | `com.atproto.repo.getRecord` | Collection + rkey → full record JSON | 214 - 215 - Accessible via Settings → Dev Tools. No separate Bloc needed — use a 216 - `DevToolsCubit` with simple request/response state, since this is a stateless 217 - exploration tool.
-307
docs/specs/phase-3.md
··· 1 - # Phase 3 2 - 3 - ## Post Composition 4 - 5 - Posts are created via `com.atproto.repo.createRecord` with collection `app.bsky.feed.post`. 6 - The compose screen is a full-screen modal opened from a floating action button on the home screen. 7 - 8 - ### Post Record Structure 9 - 10 - | Field | Type | Description | 11 - | ----------- | -------- | --------------------------------------------- | 12 - | `text` | string | Post body, max 300 graphemes | 13 - | `facets` | array | Rich text annotations (mentions, links, tags) | 14 - | `embed` | union | Attached media, link card, or quote post | 15 - | `reply` | object | `parent` + `root` refs for threaded replies | 16 - | `langs` | array | BCP-47 language tags | 17 - | `createdAt` | datetime | ISO 8601 timestamp | 18 - 19 - ### Media Uploads 20 - 21 - Upload images via `com.atproto.repo.uploadBlob`. Returns a `blob` ref used in the embed object. 22 - 23 - | Constraint | Value | 24 - | -------------- | ---------------------------------- | 25 - | Max images | 4 per post | 26 - | Max file size | 1 MB per image | 27 - | Accepted types | JPEG, PNG, WebP | 28 - | Alt text | Required UI field, optional in API | 29 - 30 - Embed type for images: `app.bsky.embed.images`. 31 - Each image entry has `image` (blob ref), `alt` (string), and optional `aspectRatio` (`width` / `height`). 32 - 33 - ### Video Uploads 34 - 35 - Upload video via `app.bsky.video.uploadVideo` on the `video.bsky.app` service. 36 - Video processing is asynchronous — the endpoint returns a `JobStatus` with a `jobId`. 37 - Poll `app.bsky.video.getJobStatus` until `state` is `JOB_STATE_COMPLETED` (returns the final `blob` ref) or `JOB_STATE_FAILED`. 38 - 39 - Before uploading, call `app.bsky.video.getUploadLimits` to check the user's remaining daily quota (`canUpload`, `remainingDailyVideos`, `remainingDailyBytes`). 40 - If the user cannot upload, show the server-provided `message` or a fallback explaining the daily limit. 41 - 42 - | Constraint | Value | 43 - | -------------- | -------------------------------------------- | 44 - | Max videos | 1 per post (mutually exclusive with images) | 45 - | Max file size | 100 MB | 46 - | Accepted types | MP4 | 47 - | Alt text | Optional UI field | 48 - | Captions | Optional; `EmbedVideoCaption` with lang code | 49 - 50 - Embed type for video: `app.bsky.embed.video`. Fields: 51 - 52 - | Field | Type | Description | 53 - | -------------- | ------------------- | -------------------------------------- | 54 - | `video` | blob | Processed video blob from job status | 55 - | `alt` | string | Accessibility description | 56 - | `aspectRatio` | object | `width` / `height` integers | 57 - | `captions` | array | Caption files with BCP-47 `lang` codes | 58 - | `presentation` | string | `"default"` or `"gif"` playback hint | 59 - 60 - The compose screen must show a progress indicator during video upload and processing. 61 - Disable the submit button until processing completes. If the job fails, display the error message from `JobStatus.error` and allow the user to retry or remove the video. 62 - 63 - A post embeds either images **or** a video, never both. When a video is attached, the image picker should be disabled (and vice versa). 64 - Switching media type should prompt the user to confirm replacing the existing attachment(s). 65 - 66 - ### Facet Detection 67 - 68 - Use **bluesky_text** to detect mentions, links, and hashtags in the post text and produce the `facets` array automatically before submission. 69 - The compose screen should render a live preview of detected facets with color-coded highlights as the user types. 70 - 71 - ### Grapheme Counter 72 - 73 - Display a live character counter showing remaining graphemes (300 max). 74 - Use Dart's `Characters` class for accurate grapheme cluster counting. 75 - Disable the submit button when the count exceeds 300 or the text is empty. 76 - 77 - ### Drafts 78 - 79 - Persist unsent posts locally in a Drift `drafts` table. 80 - On network failure or explicit save, always store the draft. Drafts are account-scoped. 81 - 82 - | Column | Type | Notes | 83 - | ------------- | -------- | ---------------------------------------- | 84 - | `id` | integer | PK autoincrement | 85 - | `account_did` | text | FK to `accounts` | 86 - | `text` | text | Post body | 87 - | `reply_uri` | text | Nullable; parent post URI if reply | 88 - | `embed_json` | text | Nullable; serialised embed data | 89 - | `media_paths` | text | Nullable; JSON array of local file paths | 90 - | `created_at` | datetime | When the draft was created | 91 - | `updated_at` | datetime | Last modification | 92 - 93 - Display a "Drafts" entry in the compose screen accessible via a toolbar icon. 94 - Tapping a draft loads it back into the composer for editing / sending. 95 - 96 - ### Scheduled Posts 97 - 98 - Schedule posts for future publication using a local scheduler. 99 - Store the scheduled time alongside the draft. Use a `WorkManager` (Android) / `BGTaskScheduler` (iOS) background task to submit the post at the scheduled time. 100 - If the device is offline at the scheduled time, queue the post and retry when connectivity resumes. 101 - 102 - Add a `scheduled_at` (nullable datetime) column to the `drafts` table. 103 - When non-null, the draft is treated as scheduled rather than a regular draft. 104 - 105 - Build a `ComposeBloc` with events: `TextChanged`, `MediaAttached`, `MediaRemoved`, `AltTextUpdated`, `VideoAttached`, `VideoRemoved`, `DraftSaved`, `DraftLoaded`, `PostScheduled`, `PostSubmitted`. 106 - 107 - ## Notifications 108 - 109 - Render BlueSky notifications using `app.bsky.notification.listNotifications`. 110 - No push notifications in this phase — polling only. 111 - 112 - ### API 113 - 114 - | Endpoint | Purpose | 115 - | ----------------------------------------- | --------------------------- | 116 - | `app.bsky.notification.listNotifications` | Paginated notification list | 117 - | `app.bsky.notification.updateSeen` | Mark notifications as read | 118 - | `app.bsky.notification.getUnreadCount` | Badge count for nav bar | 119 - 120 - `listNotifications` returns `notification` objects with `reason`, `author`, 121 - `record`, `isRead`, `indexedAt`. Paginate with `cursor`; `limit` 1–100. 122 - 123 - ### Notification Reasons 124 - 125 - | Reason | Display | 126 - | --------- | ---------------------------------------------- | 127 - | `like` | "[Author] liked your post" + post preview | 128 - | `repost` | "[Author] reposted your post" + post preview | 129 - | `follow` | "[Author] followed you" | 130 - | `mention` | "[Author] mentioned you" + post preview | 131 - | `reply` | "[Author] replied to your post" + post preview | 132 - | `quote` | "[Author] quoted your post" + post preview | 133 - 134 - ### Rendering 135 - 136 - Group notifications by day. Each notification row shows the author avatar, the reason icon, a summary line, and an optional post preview snippet. 137 - Tapping a notification navigates to the relevant post or profile. 138 - 139 - Display an unread count badge on the Notifications nav bar item. 140 - Poll `getUnreadCount` on a 30-second interval when the app is foregrounded. 141 - Call `updateSeen` when the notifications screen is opened. 142 - 143 - Build a `NotificationBloc` with events: `NotificationsRequested`, `NotificationsRefreshed`, `NotificationsPageLoaded`, `NotificationsMarkedRead`. 144 - 145 - ## Post & Profile Actions 146 - 147 - All post and profile interactions use the AT Protocol record model. 148 - Actions that create a relationship (like, repost, follow, block) write a record via `com.atproto.repo.createRecord` and undo by deleting via `com.atproto.repo.deleteRecord`. 149 - Muting is a server-side procedure call with no persistent record. 150 - 151 - ### API 152 - 153 - | Endpoint | Purpose | 154 - | ------------------------------------- | --------------------------------------- | 155 - | `com.atproto.repo.createRecord` | Create like/repost/follow/block records | 156 - | `com.atproto.repo.deleteRecord` | Delete like/repost/follow/block records | 157 - | `app.bsky.graph.muteActor` | Mute an account | 158 - | `app.bsky.graph.unmuteActor` | Unmute an account | 159 - | `com.atproto.moderation.createReport` | Report a post or account | 160 - 161 - ### Like 162 - 163 - Collection: `app.bsky.feed.like`. 164 - Record contains a `subject` (RepoStrongRef with the post's AT-URI and CID) and `createdAt`. 165 - 166 - To unlike, extract the record key (rkey) from the `viewer.like` AT-URI and call `deleteRecord` with collection `app.bsky.feed.like` and that rkey. 167 - 168 - The `PostView.viewer.like` field is non-null when the current user has liked the post. 169 - Use this to drive the filled/outlined heart icon state. 170 - 171 - ### Repost 172 - 173 - Collection: `app.bsky.feed.repost`. Record structure is identical to like — `subject` (RepoStrongRef) + `createdAt`. 174 - 175 - To un-repost, extract the rkey from `viewer.repost` and delete the record. 176 - 177 - The `PostView.viewer.repost` field is non-null when the current user has reposted. 178 - Use this for the repost icon state. 179 - 180 - ### Follow 181 - 182 - Collection: `app.bsky.graph.follow`. Record contains `subject` (the target user's DID as a string) and `createdAt`. 183 - 184 - To unfollow, extract the rkey from `viewer.following` and delete the record. 185 - 186 - Viewer state fields on profiles: 187 - 188 - - `viewer.following` — non-null AT-URI if the current user follows this profile 189 - - `viewer.followedBy` — non-null AT-URI if this profile follows the current user 190 - 191 - ### Mute 192 - 193 - Mute and unmute are procedure calls (not record creation): 194 - 195 - - `app.bsky.graph.muteActor` — input: `{ actor: DID }` 196 - - `app.bsky.graph.unmuteActor` — input: `{ actor: DID }` 197 - 198 - Both return empty responses. The `viewer.muted` boolean on profiles reflects the current mute state. 199 - Muted accounts' posts are still fetched but should be visually de-emphasised or filtered in the UI based on user preference. 200 - 201 - ### Block 202 - 203 - Collection: `app.bsky.graph.block`. Record contains `subject` (the target user's DID) and `createdAt`. 204 - 205 - To unblock, extract the rkey from `viewer.blocking` and delete the record. 206 - 207 - Viewer state fields on profiles: 208 - 209 - - `viewer.blocking` — non-null AT-URI if the current user blocks this profile 210 - - `viewer.blockedBy` — boolean, true if this profile blocks the current user 211 - 212 - When a user is blocked, their posts should be hidden from feeds and threads. 213 - Display a "You have blocked this user" placeholder in their profile view. 214 - 215 - ### Report 216 - 217 - Reports use `com.atproto.moderation.createReport` with two subject types: 218 - 219 - | Subject Type | Usage | Fields | 220 - | --------------- | ---------------------- | ------------- | 221 - | `RepoStrongRef` | Report a specific post | `uri` + `cid` | 222 - | `RepoRef` | Report an account | `did` | 223 - 224 - Report reasons (from `com.atproto.moderation.defs`): 225 - 226 - | Reason | Description | 227 - | ------------------ | --------------------------------- | 228 - | `reasonSpam` | Spam or unsolicited content | 229 - | `reasonViolation` | Violates community guidelines | 230 - | `reasonMisleading` | Misleading or deceptive content | 231 - | `reasonSexual` | Unwanted sexual content | 232 - | `reasonRude` | Harassment or rude behaviour | 233 - | `reasonOther` | Other (requires text explanation) | 234 - 235 - The report dialog should present the reason picker and an optional free-text description field. 236 - Submitting returns a report ID for confirmation. 237 - 238 - ### Optimistic Updates 239 - 240 - All toggle actions (like, repost, follow, mute, block) should use optimistic UI updates: 241 - 242 - 1. Immediately update the local state (icon, count, button label). 243 - 2. Fire the API call in the background. 244 - 3. On success, reconcile with the server response (update the viewer URI). 245 - 4. On failure, roll back the local state and show a snackbar error. 246 - 247 - Build a `PostActionCubit` that manages per-post action state (like, repost, save). 248 - It accepts the initial `ViewerState` from the post and exposes 249 - toggleable methods. 250 - 251 - Build a `ProfileActionCubit` that manages per-profile action state (follow, mute, block). 252 - It accepts the initial `ViewerState` from the profile and exposes toggleable methods. 253 - 254 - ### Post Action Bar 255 - 256 - The post action bar appears below every post and contains four buttons: 257 - 258 - | Button | Icon | Tap action | Long-press | 259 - | ------ | ------ | ------------- | ----------------- | 260 - | Reply | chat | Open compose | — | 261 - | Repost | repeat | Toggle repost | Quote post option | 262 - | Like | heart | Toggle like | — | 263 - | Share | share | Share sheet | — | 264 - 265 - The bookmark (save) icon is placed in the post overflow menu alongside "Report" and "Copy link". 266 - 267 - Like and repost counts are displayed next to their respective icons. Counts update optimistically. 268 - The repost long-press opens a bottom sheet with "Repost" and "Quote Post" options. 269 - 270 - ### Profile Action Buttons 271 - 272 - The profile header shows a primary action button based on the relationship: 273 - 274 - | State | Button label | Tap action | 275 - | ---------------- | ------------ | -------------- | 276 - | Not following | "Follow" | Create follow | 277 - | Following | "Following" | Unfollow sheet | 278 - | Blocked by them | — | No button | 279 - | You blocked them | "Unblock" | Delete block | 280 - 281 - The profile overflow menu (three-dot icon) contains: Mute / Unmute, Block / Unblock, Report, Copy DID, Share profile. 282 - 283 - Mute and block actions should show a confirmation dialog before proceeding. 284 - 285 - ## Saved Posts 286 - 287 - Allow users to bookmark posts locally for later reading. Saved posts are stored only in Drift — nothing is written to the network. 288 - This is intentionally private and local-only. 289 - 290 - ### Drift Table 291 - 292 - | Column | Type | Notes | 293 - | ------------- | -------- | ---------------------------- | 294 - | `id` | integer | PK autoincrement | 295 - | `account_did` | text | FK to `accounts` | 296 - | `post_uri` | text | AT-URI of the saved post | 297 - | `post_json` | text | Full serialised post payload | 298 - | `saved_at` | datetime | When the user saved the post | 299 - 300 - Unique constraint on (`account_did`, `post_uri`). 301 - 302 - ### UI 303 - 304 - Add a "Save" action (bookmark icon) to the post action bar. Tapping toggles the saved state. 305 - Saved posts are viewable from a "Saved" section in the profile screen or settings. 306 - 307 - Build a `SavedPostsCubit` that reads/writes the `saved_posts` table and exposes a stream of saved post URIs for quick lookup (to show filled vs outlined bookmark icons in the feed).
-600
docs/specs/phase-4.md
··· 1 - # Phase 4 2 - 3 - ## Direct Messages 4 - 5 - DMs use the `chat.bsky.*` lexicon namespace. The DM feature has two views: a 6 - conversation list and a message thread. 7 - 8 - ### API 9 - 10 - | Endpoint | Purpose | 11 - | -------------------------------------- | ----------------------------- | 12 - | `chat.bsky.convo.listConvos` | Paginated conversation list | 13 - | `chat.bsky.convo.getConvo` | Single conversation metadata | 14 - | `chat.bsky.convo.getMessages` | Paginated messages in a convo | 15 - | `chat.bsky.convo.sendMessage` | Send a message | 16 - | `chat.bsky.convo.deleteMessageForSelf` | Delete a message locally | 17 - | `chat.bsky.convo.muteConvo` | Mute a conversation | 18 - | `chat.bsky.convo.unmuteConvo` | Unmute a conversation | 19 - | `chat.bsky.convo.updateRead` | Mark conversation as read | 20 - | `chat.bsky.convo.getLog` | Polling for new events | 21 - 22 - ### Conversation List 23 - 24 - `listConvos` returns conversations sorted by last message time. Each convo 25 - includes `id`, `members` (array of `profileViewBasic`), `lastMessage`, 26 - `unreadCount`, `muted`. 27 - 28 - Filter conversations into two tabs: **Primary** (accepted) and **Requests** 29 - (conversations the user has not yet responded to). A conversation is a 30 - "request" if the user has never sent a message in it. 31 - 32 - ### Message Thread 33 - 34 - `getMessages` returns paginated `messageView` objects. Each message has `id`, 35 - `text`, `sender` (DID), `sentAt`. Messages are displayed in a standard chat 36 - bubble layout — the current user's messages right-aligned, others left-aligned. 37 - 38 - Support long-press to copy individual messages. Provide a "Copy All" option in 39 - the conversation overflow menu to copy the full thread. 40 - 41 - ### Sending Messages 42 - 43 - `sendMessage` takes `convoId` and `message` (object with `text`). To start a 44 - new conversation, the app calls `chat.bsky.convo.getConvoForMembers` with the 45 - target DID(s) — this returns an existing convo or creates a new one. 46 - 47 - | Endpoint | Purpose | 48 - | ------------------------------------ | --------------------- | 49 - | `chat.bsky.convo.getConvoForMembers` | Get or create a convo | 50 - 51 - Build a `ConvoListBloc` with events: `ConvosRequested`, `ConvosRefreshed`, 52 - `ConvoMuted`, `ConvoUnmuted`. 53 - 54 - Build a `MessageBloc` with events: `MessagesRequested`, `MessagesPageLoaded`, 55 - `MessageSent`, `MessageDeleted`, `ConvoMarkedRead`. 56 - 57 - ## Media Playback & Download 58 - 59 - Currently images open in an external browser and videos launch an external app 60 - via `url_launcher`. This milestone adds in-app media viewing and the ability to 61 - save media to the device gallery. 62 - 63 - ### Packages 64 - 65 - | Package | Purpose | 66 - | -------------------- | --------------------------------------------------------------- | 67 - | `photo_view` | Pinch-to-zoom and pan for full-screen images | 68 - | `video_player` | Flutter's official video playback plugin (HLS support built-in) | 69 - | `chewie` | Material-styled controls wrapper around `video_player` | 70 - | `dio` | HTTP downloads with progress callbacks | 71 - | `gal` | Save images and videos to the device gallery | 72 - | `permission_handler` | Request photo-library / storage write permissions | 73 - 74 - ### Image Viewer 75 - 76 - Tapping an image in a post opens a full-screen `ImageViewerScreen`. The screen 77 - is a `PageView` so multi-image posts are swipeable. Each page contains a 78 - `PhotoView` widget wrapping an `Image.network` of the `fullsize` URL. A hero 79 - animation on the thumbnail provides a smooth transition. 80 - 81 - The viewer has a transparent app bar with a close button, a download button, and 82 - a share button. Swiping down dismisses the viewer. The current page indicator 83 - appears at the bottom for multi-image posts. 84 - 85 - Alt text, when present, is shown in a semi-transparent bar at the bottom of 86 - each page. 87 - 88 - ### Video Player 89 - 90 - Tapping a video embed opens a `VideoPlayerScreen`. The player uses `chewie` 91 - wrapping Flutter's `VideoPlayerController.networkUrl` pointed at the HLS 92 - `playlist` URL. Controls include play/pause, seek bar, elapsed/total time, 93 - fullscreen toggle, and a mute button. 94 - 95 - The video thumbnail is shown as a placeholder until the player initialises. If 96 - the embed has an `aspectRatio`, the player container uses it; otherwise it 97 - defaults to 16:9. The player disposes its controller on screen pop. 98 - 99 - For GIF-style videos (`presentation: "gif"`), the player auto-plays in a loop 100 - with controls hidden and audio muted. 101 - 102 - ### Downloading Media 103 - 104 - A download button appears in the image viewer toolbar and the video player 105 - toolbar. The download flow: 106 - 107 - 1. Check and request write permission via `permission_handler` (photo library 108 - on iOS, storage or media-store on Android). 109 - 2. Download the file using `dio` with a progress callback driving a circular 110 - progress indicator on the button. 111 - 3. Save the file to the device gallery via `gal`. 112 - 4. Show a snackbar confirming success or displaying the error. 113 - 114 - For images, the download URL is the `fullsize` URL. For videos, download the 115 - highest-quality variant from the HLS playlist. Parse the `.m3u8` manifest to 116 - find the highest-bandwidth variant URL, then download that MP4 stream. 117 - 118 - Long-press on an image thumbnail in a post (without entering the viewer) should 119 - show a context menu with "Save image" and "Share" options. 120 - 121 - ### Permissions 122 - 123 - | Platform | Permission | When Requested | 124 - | ----------- | --------------------------------------- | ---------------------- | 125 - | iOS | `NSPhotoLibraryAddUsageDescription` | First download attempt | 126 - | Android 13+ | `READ_MEDIA_IMAGES`, `READ_MEDIA_VIDEO` | First download attempt | 127 - | Android <13 | `WRITE_EXTERNAL_STORAGE` | First download attempt | 128 - 129 - Declare permissions in `AndroidManifest.xml` and `Info.plist`. The app only 130 - requests permission at the moment of download, not on launch. 131 - 132 - ## Account Switching 133 - 134 - Support multiple authenticated accounts with full data isolation. The 135 - `accounts` table (from Phase 1) already supports multiple rows keyed by DID. 136 - 137 - ### Active Account 138 - 139 - Store the active account DID in the Drift `settings` table under key 140 - `active_account_did`. On launch, read this value and restore the session for 141 - that account. 142 - 143 - ### Data Isolation 144 - 145 - All user-scoped tables must include an `account_did` FK column. Queries always 146 - filter by the active account's DID. Tables requiring this constraint: 147 - 148 - - `drafts` 149 - - `saved_posts` 150 - - `search_history` (Phase 2) 151 - - `cached_posts` (add `account_did` if not present) 152 - 153 - ### Switching Flow 154 - 155 - 1. User opens account switcher (bottom sheet or settings). 156 - 2. Selects a different account. 157 - 3. App updates `active_account_did` in settings. 158 - 4. All Blocs receive a `AccountSwitched` event and reload their state for the 159 - new account. 160 - 5. If the selected account's tokens are expired, attempt silent refresh. If 161 - refresh fails, navigate to login. 162 - 163 - ### Adding Accounts 164 - 165 - "Add Account" triggers the same OAuth flow from Phase 1. On success, a new row 166 - is inserted into `accounts`. The new account becomes the active account. 167 - 168 - Build an `AccountSwitcherCubit` that exposes the list of accounts and the 169 - active DID. 170 - 171 - ## Offline Reading 172 - 173 - The app should render cached data when the network is unavailable. This builds 174 - on the `cached_posts` and `cached_profiles` tables from Phase 1. 175 - 176 - ### Cache Strategy 177 - 178 - Cache the last-fetched page of each feed (timeline, pinned generators) in Drift 179 - as serialised JSON. On launch or feed switch, display cached data immediately, 180 - then fetch fresh data in the background. If the fetch fails, keep showing the 181 - cache with a "You're offline" banner. 182 - 183 - ### Offline Indicators 184 - 185 - - A persistent banner at the top of the screen when connectivity is lost. 186 - - Disable actions that require network (compose, like, repost, follow) and show 187 - a tooltip explaining why. 188 - - Notifications and DM screens show an empty state with "No connection" when 189 - offline and no cached data exists. 190 - 191 - ### Network Detection 192 - 193 - Use the **connectivity_plus** package to monitor network state changes. Expose 194 - connectivity as a stream via a `ConnectivityCubit` that all screens observe. 195 - 196 - ## Jump to Profile 197 - 198 - Add a floating action button on the search screen. Tapping it opens a dialog 199 - with a text field for entering a handle. Use 200 - `app.bsky.actor.searchActorsTypeahead` to provide autocomplete suggestions as 201 - the user types. Selecting a result or pressing enter navigates to that user's 202 - profile screen. 203 - 204 - | Endpoint | Purpose | 205 - | -------------------------------------- | ------------------- | 206 - | `app.bsky.actor.searchActorsTypeahead` | Handle autocomplete | 207 - | `app.bsky.actor.getProfile` | Full profile fetch | 208 - 209 - ## Labelers & Content Moderation 210 - 211 - Labelers are independent services that produce metadata labels about content 212 - and accounts. Users subscribe to labelers and configure how each label type 213 - affects their experience. The `bluesky` Dart package includes a built-in 214 - moderation decision engine that handles label interpretation. 215 - 216 - ### Architecture Overview 217 - 218 - ```text 219 - User Preferences ──► ModerationOpts ──► moderatePost() ──► ModerationDecision 220 - Label Definitions ─┘ moderateProfile() └─► getUI(context) 221 - moderateNotification() └─► ModerationUI 222 - ├─ filters 223 - ├─ blurs 224 - ├─ alerts 225 - └─ informs 226 - ``` 227 - 228 - ### API 229 - 230 - | Endpoint | Purpose | 231 - | ------------------------------- | ----------------------------------- | 232 - | `app.bsky.labeler.getServices` | Fetch labeler details by DID | 233 - | `app.bsky.actor.getPreferences` | Read labeler subscriptions + prefs | 234 - | `app.bsky.actor.putPreferences` | Write labeler subscriptions + prefs | 235 - 236 - ### Labeler Subscriptions 237 - 238 - Users subscribe to up to 20 labelers. Subscriptions are stored as a 239 - `labelersPref` entry in the user's preferences. Each entry contains a labeler 240 - DID. The official Bluesky moderation labeler is always active and does not 241 - count against the 20-labeler limit. 242 - 243 - On every XRPC request that returns content (feed, thread, profile, search, 244 - notifications), include the `atproto-accept-labelers` HTTP header with a 245 - comma-separated list of subscribed labeler DIDs. The AppView fetches labels 246 - from those labelers and attaches them to the response. 247 - 248 - ### Label Data Model 249 - 250 - A label is a lightweight annotation attached to content or an account: 251 - 252 - | Field | Type | Description | 253 - | ----- | --------- | --------------------------------------------------- | 254 - | `src` | string | DID of the labeler that created the label | 255 - | `uri` | string | AT-URI of the target resource (or DID for accounts) | 256 - | `cid` | string? | Optional CID for a specific version of the target | 257 - | `val` | string | Label value identifier (e.g. "porn", "spam") | 258 - | `neg` | bool? | If true, negates (retracts) a previous label | 259 - | `cts` | datetime | Creation timestamp | 260 - | `exp` | datetime? | Expiration timestamp | 261 - 262 - ### Label Behaviour Definitions 263 - 264 - Each label value is defined by three axes that determine its UI effect: 265 - 266 - | Axis | Values | Description | 267 - | ---------------- | -------------------------- | -------------------------------- | 268 - | `blurs` | `content`, `media`, `none` | What gets blurred | 269 - | `severity` | `inform`, `alert`, `none` | Badge type (neutral vs warning) | 270 - | `defaultSetting` | `ignore`, `warn`, `hide` | Default user-configurable action | 271 - 272 - Global (protocol-defined) label values include: 273 - 274 - | Label | Blurs | Default | Notes | 275 - | ---------------- | ------- | ------- | ------------------------------- | 276 - | `!hide` | content | hide | Non-configurable, no override | 277 - | `!warn` | content | warn | Non-configurable, click-through | 278 - | `porn` | media | hide | Adult, 18+ required | 279 - | `sexual` | media | warn | Adult, 18+ required | 280 - | `graphic-media` | media | warn | Adult, 18+ required | 281 - | `nudity` | media | ignore | Not 18+ restricted | 282 - | `dmca-violation` | content | hide | Non-configurable | 283 - | `doxxing` | content | hide | Non-configurable | 284 - 285 - Labelers may also define custom label values with localised names and 286 - descriptions via `LabelValueDefinition`. 287 - 288 - ### Self-Labels 289 - 290 - Authors can embed `selfLabels` directly in their posts and profiles. Only 291 - global label values are valid as self-labels. Self-labels are treated as if 292 - the author is the label source and follow the same behaviour rules. 293 - 294 - ### Moderation Decision Pipeline 295 - 296 - Use the `bluesky` package's moderation engine for all content display: 297 - 298 - 1. **Build `ModerationOpts`** from user preferences: 299 - - `adultContentEnabled` — boolean from preferences 300 - - `labels` — map of label value → preference (`ignore` / `warn` / `hide`) 301 - - `labelers` — list of subscribed labeler DIDs 302 - - `labelDefs` — map of labeler DID → list of custom `InterpretedLabelValueDefinition` 303 - 304 - 2. **Run moderation** on every piece of content before display: 305 - - `moderatePost(subject, opts)` for posts 306 - - `moderateProfile(subject, opts)` for profiles 307 - - `moderateNotification(subject, opts)` for notifications 308 - 309 - 3. **Apply `ModerationUI`** via `decision.getUI(context)` for each display 310 - context: 311 - - `contentList` — post in a feed or search results 312 - - `contentView` — post in a thread view 313 - - `contentMedia` — images/media within a post 314 - - `avatar` — profile avatar 315 - - `profileList` — profile in a list 316 - - `profileView` — full profile screen 317 - 318 - The `ModerationUI` object contains: 319 - 320 - - `filters` — content should be removed from the list entirely 321 - - `blurs` — content should be placed behind a click-through overlay 322 - - `alerts` — show a warning badge (negative connotation) 323 - - `informs` — show an informational badge (neutral) 324 - - `noOverride` — blur cannot be dismissed by the user 325 - 326 - ### Content Label Preferences 327 - 328 - Users configure per-label visibility via `contentLabelPref` entries in 329 - preferences. Each entry specifies: 330 - 331 - | Field | Description | 332 - | ------------ | ------------------------------------------- | 333 - | `labelerDid` | Scope to a specific labeler (null = global) | 334 - | `label` | The label value string | 335 - | `visibility` | `ignore`, `warn`, `hide`, or `show` | 336 - 337 - Labels with `adultOnly: true` in their definition require the user to have 338 - `adultContentEnabled` set to true. If adult content is disabled, these labels 339 - always apply as `hide` regardless of user preference. 340 - 341 - ### Rendering Rules 342 - 343 - **Blur overlay**: When `blurs` is non-empty, render a semi-transparent overlay 344 - with the label name and a "Show" button. If `noOverride` is true, omit the 345 - "Show" button — the content cannot be revealed. 346 - 347 - **Alert badge**: When `alerts` is non-empty, show a warning icon with the 348 - label name below the content or next to the profile name. 349 - 350 - **Inform badge**: When `informs` is non-empty, show an info icon with the 351 - label name. Use a neutral colour (not red/warning). 352 - 353 - **Filtering**: When `filters` is non-empty, remove the content from the 354 - current list view entirely. Do not render a placeholder. 355 - 356 - **Media blur**: When the `contentMedia` context has blurs, blur only images 357 - and embedded media while leaving the post text visible. 358 - 359 - **Avatar blur**: When the `avatar` context has blurs, show a generic 360 - placeholder avatar. 361 - 362 - ### ModerationService 363 - 364 - Build a `ModerationService` that: 365 - 366 - 1. Loads labeler subscriptions and label preferences from user preferences on 367 - login and account switch. 368 - 2. Fetches labeler details (`getServices` with `detailed: true`) for all 369 - subscribed labelers to obtain custom label definitions. 370 - 3. Caches label definitions in a Drift `labeler_cache` table for offline use. 371 - 4. Constructs `ModerationOpts` and exposes it as a stream that updates when 372 - preferences change. 373 - 5. Provides convenience methods: `moderatePost()`, `moderateProfile()`, 374 - `moderateNotification()`. 375 - 376 - ### Labeler Cache Table 377 - 378 - | Column | Type | Notes | 379 - | --------------- | -------- | -------------------------------- | 380 - | `labeler_did` | text | PK, DID of the labeler | 381 - | `policies_json` | text | Serialised `LabelerViewDetailed` | 382 - | `fetched_at` | datetime | When the data was last refreshed | 383 - 384 - Refresh the cache when the user opens the labeler management screen or when a 385 - subscribed labeler's data is older than 24 hours. 386 - 387 - ### Labeler Management UI 388 - 389 - The labeler management screen (accessible from Settings) shows: 390 - 391 - - A list of subscribed labelers, each showing: creator avatar, display name, 392 - description, and number of label definitions. 393 - - Tapping a labeler opens a detail screen showing all label values the labeler 394 - publishes, with the user's current preference (ignore/warn/hide) for each. 395 - - A toggle for subscribing/unsubscribing to the labeler. 396 - - An "Adult content" toggle at the top of the settings screen that gates all 397 - 18+ label preferences. 398 - 399 - ### Header Integration 400 - 401 - The XRPC client must be modified to include the `atproto-accept-labelers` 402 - header on all outgoing requests. The header value is a comma-separated list of 403 - labeler DIDs from the user's `labelersPref`. This should be set once on login 404 - and updated whenever preferences change. 405 - 406 - ## Lists 407 - 408 - Lists let users organise accounts into named collections. There are two 409 - user-facing list types: **curation lists** (curated feeds of member posts) and 410 - **moderation lists** (mute or block every member at once). A third type, 411 - **reference lists**, exists only as the backing store for starter packs. 412 - 413 - ### API 414 - 415 - | Endpoint | Purpose | 416 - | --------------------------------------- | ---------------------------------------- | 417 - | `app.bsky.graph.getList` | Paginated list view with member profiles | 418 - | `app.bsky.graph.getLists` | Lists created by an actor | 419 - | `app.bsky.graph.getListsWithMembership` | User's lists with membership info | 420 - | `app.bsky.graph.getListMutes` | Mod lists the user has muted | 421 - | `app.bsky.graph.getListBlocks` | Mod lists the user is blocking | 422 - | `app.bsky.feed.getListFeed` | Feed of posts from list members | 423 - | `app.bsky.graph.muteActorList` | Mute all accounts on a list | 424 - | `app.bsky.graph.unmuteActorList` | Unmute a previously muted list | 425 - 426 - List records, list items, and list blocks are managed through 427 - `com.atproto.repo.createRecord`, `putRecord`, and `deleteRecord` with the 428 - appropriate collection (`app.bsky.graph.list`, `app.bsky.graph.listitem`, 429 - `app.bsky.graph.listblock`). 430 - 431 - ### List Purposes 432 - 433 - | Purpose | Constant | Usage | 434 - | --------------- | ----------------------------------- | --------------------------------------- | 435 - | Curation list | `app.bsky.graph.defs#curatelist` | User-curated, viewable as a feed | 436 - | Moderation list | `app.bsky.graph.defs#modlist` | Mute or block all members at once | 437 - | Reference list | `app.bsky.graph.defs#referencelist` | Internal backing list for starter packs | 438 - 439 - ### List Record 440 - 441 - | Field | Type | Notes | 442 - | ------------------- | --------- | --------------------------------- | 443 - | `purpose` | string | One of the list purpose constants | 444 - | `name` | string | 1–64 graphemes | 445 - | `description` | string? | Max 300 graphemes / 3000 bytes | 446 - | `descriptionFacets` | facets[]? | Rich text facets for description | 447 - | `avatar` | blob? | PNG or JPEG, max 1 MB | 448 - | `createdAt` | datetime | Required | 449 - 450 - ### List Item Record 451 - 452 - | Field | Type | Notes | 453 - | ----------- | -------- | ------------------------- | 454 - | `subject` | string | DID of the account to add | 455 - | `list` | string | AT-URI of the parent list | 456 - | `createdAt` | datetime | Required | 457 - 458 - Duplicate list items for the same subject are ignored by the AppView. 459 - 460 - ### List Views 461 - 462 - `listView` includes: `uri`, `cid`, `creator` (profileView), `name`, 463 - `purpose`, `description?`, `avatar?`, `listItemCount?`, `labels?`, 464 - `viewer?` (listViewerState with `muted?` and `blocked?`), `indexedAt`. 465 - 466 - `listItemView` includes: `uri` (of the listitem record), `subject` 467 - (profileView). 468 - 469 - ### List Feed 470 - 471 - `getListFeed` returns `feedViewPost` items — posts and reposts from list 472 - members. Only curation lists support feeds. The existing feed rendering 473 - infrastructure can be reused. 474 - 475 - ### Screens 476 - 477 - **My Lists screen** (accessible from profile or settings): shows lists the 478 - user has created, separated by curation and moderation tabs. Each row shows 479 - name, avatar, member count, and purpose badge. FAB to create a new list. 480 - 481 - **List detail screen**: shows the list header (name, avatar, description, 482 - creator, member count) and two tabs — **Feed** (for curation lists, using 483 - `getListFeed`) and **Members** (paginated member profiles). The overflow 484 - menu includes: edit list, delete list, add/remove members, and for mod 485 - lists — mute list / block via list. 486 - 487 - **Add/remove members**: a screen with a search field using 488 - `searchActorsTypeahead` to find users, plus a list of current members with 489 - remove buttons. Adding creates a `listitem` record; removing deletes it. 490 - 491 - **Create/edit list dialog**: name, description, avatar picker, and purpose 492 - selector (curation or moderation — reference lists are not user-created 493 - directly). 494 - 495 - ### Bloc Architecture 496 - 497 - Build a `ListBloc` with events: `ListRequested`, `ListRefreshed`, 498 - `ListItemAdded`, `ListItemRemoved`, `ListMuted`, `ListUnmuted`, 499 - `ListBlocked`, `ListUnblocked`. 500 - 501 - Build a `MyListsCubit` that loads the user's lists via `getLists`. 502 - 503 - Build a `ListFeedBloc` reusing the feed pagination pattern from the home 504 - feed, backed by `getListFeed`. 505 - 506 - ### Profile Integration 507 - 508 - On profile screens, add a "Lists" tab that shows lists created by that 509 - actor via `getLists`. Also add an "Add to list" option in the profile 510 - overflow menu that shows `getListsWithMembership` and lets the user toggle 511 - membership. 512 - 513 - ## Starter Packs 514 - 515 - Starter packs are curated bundles of recommended accounts and feeds, 516 - designed to help new users bootstrap their experience. Each starter pack is 517 - backed by a reference list that holds the recommended accounts. 518 - 519 - ### API 520 - 521 - | Endpoint | Purpose | 522 - | ---------------------------------------------- | ----------------------------------------- | 523 - | `app.bsky.graph.getStarterPack` | Detailed view of a single starter pack | 524 - | `app.bsky.graph.getStarterPacks` | Batch-fetch up to 25 starter packs by URI | 525 - | `app.bsky.graph.getActorStarterPacks` | Starter packs created by an actor | 526 - | `app.bsky.graph.getStarterPacksWithMembership` | User's starter packs with membership info | 527 - | `app.bsky.graph.searchStarterPacks` | Search starter packs by query string | 528 - 529 - Starter pack records are managed through `com.atproto.repo.createRecord`, 530 - `putRecord`, and `deleteRecord` with collection 531 - `app.bsky.graph.starterpack`. 532 - 533 - ### Starter Pack Record 534 - 535 - | Field | Type | Notes | 536 - | ------------------- | ----------- | -------------------------------- | 537 - | `name` | string | 1–50 graphemes | 538 - | `list` | string | AT-URI to a reference list | 539 - | `description` | string? | Max 300 graphemes / 3000 bytes | 540 - | `descriptionFacets` | facets[]? | Rich text facets for description | 541 - | `feeds` | feedItem[]? | Up to 3 feed generator URIs | 542 - | `createdAt` | datetime | Required | 543 - 544 - `feedItem` contains a single `uri` field pointing to a feed generator 545 - record. 546 - 547 - ### Starter Pack Views 548 - 549 - `starterPackView` (full): `uri`, `cid`, `record`, `creator` 550 - (profileViewBasic), `list?` (listViewBasic), `listItemsSample?` (up to 12 551 - listItemView), `feeds?` (up to 3 generatorView), `joinedWeekCount?`, 552 - `joinedAllTimeCount?`, `labels?`, `indexedAt`. 553 - 554 - `starterPackViewBasic` (compact): `uri`, `cid`, `record`, `creator` 555 - (profileViewBasic), `listItemCount?`, `joinedWeekCount?`, 556 - `joinedAllTimeCount?`, `labels?`, `indexedAt`. 557 - 558 - ### Creation Flow 559 - 560 - 1. Create a reference list via `com.atproto.repo.createRecord` with 561 - collection `app.bsky.graph.list` and purpose `referencelist`. 562 - 2. Add members to the reference list by creating `listitem` records. 563 - 3. Create the starter pack record pointing to the reference list. 564 - 565 - Updating the people in a starter pack means adding/removing `listitem` 566 - records on its backing reference list. Updating the name, description, or 567 - feeds means updating the starter pack record itself. 568 - 569 - ### Screens 570 - 571 - **Starter pack detail screen**: shows name, description, creator profile, 572 - join stats (`joinedWeekCount`, `joinedAllTimeCount`), a sample of members 573 - (up to 12 from `listItemsSample`), and recommended feeds. A "See all 574 - members" button navigates to the full member list via the backing reference 575 - list. A "Follow all" button follows every member in the pack. 576 - 577 - **Actor starter packs screen**: shows starter packs created by a specific 578 - user, accessible from their profile. Uses `getActorStarterPacks` with 579 - pagination. 580 - 581 - **Create/edit starter pack screen**: name (max 50 graphemes), description, 582 - member search (using `searchActorsTypeahead`), and feed picker (up to 3 583 - feeds from `getActorFeeds` or `getSuggestedFeeds`). On save, the app 584 - creates the reference list, adds members, then creates the starter pack 585 - record. 586 - 587 - ### Bloc Architecture 588 - 589 - Build a `StarterPackBloc` with events: `StarterPackRequested`, 590 - `StarterPackCreated`, `StarterPackUpdated`, `StarterPackDeleted`, 591 - `MemberAdded`, `MemberRemoved`. 592 - 593 - Build a `ActorStarterPacksCubit` that loads starter packs for a given 594 - actor via `getActorStarterPacks`. 595 - 596 - ### Profile Integration 597 - 598 - On profile screens, add a "Starter Packs" section or tab showing packs 599 - created by that actor. Starter pack cards show the pack name, creator, 600 - member count, and join stats.
-199
docs/specs/phase-7.md
··· 1 - --- 2 - title: Phase 7 Spec 3 - updated: 2026-04-09 4 - --- 5 - 6 - ## Semantic Search for Saved & Liked Posts 7 - 8 - On-device vector search over the user's saved and liked posts. 9 - Posts are embedded at save/like time using an on-device text embedding model, stored in ObjectBox with HNSW indexing, and queried via natural-language input. 10 - The entire pipeline runs locally -- no data leaves the device. 11 - 12 - ### Why ObjectBox + TFLite (not MediaPipe) 13 - 14 - **ObjectBox** (`objectbox` ^5.3.1) is the only Flutter-native vector DB with production-grade HNSW support. 15 - It provides `@HnswIndex` annotations, `nearestNeighborsF32` queries, and composable filters -- exactly what's needed. 16 - 17 - **TFLite via `tflite_flutter`** (^0.12.1) is the embedding runtime. 18 - MediaPipe's Flutter package (`mediapipe_text` 0.0.1) requires the Flutter master channel and the experimental `--enable-experiment=native-assets` flag, making it unsuitable for production. 19 - `tflite_flutter` is stable, runs on both iOS and Android, and can load the same TFLite models MediaPipe would use internally. 20 - 21 - **Embedding model:** MiniLM-L6-v2 (all-MiniLM-L6-v2), quantized to INT8. 22 - 384-dimensional output, ~25 MB model file, ~15ms inference on mid-range devices. 23 - Widely deployed, well-understood, Apache 2.0 licensed. Bundled as a Flutter asset. 24 - 25 - > Alternative considered: EmbeddingGemma (768D, ~200 MB). Better quality but 8x the model size -- too large for a bundled mobile asset. 26 - > MiniLM's 384D is sufficient for post-length text and keeps the app install size reasonable. 27 - 28 - ### Data Flow 29 - 30 - ```text 31 - Post saved/liked 32 - → Extract searchable text (post text + alt text from images + link card title/description) 33 - → Run TFLite inference in background Isolate → Float32List[384] 34 - → Store in ObjectBox (EmbeddedPost entity with HNSW-indexed vector) 35 - 36 - User searches 37 - → Embed query string via same model → Float32List[384] 38 - → ObjectBox nearestNeighborsF32(queryVector, maxResults) 39 - → Map results back to cached/saved posts → display 40 - ``` 41 - 42 - ### ObjectBox Entity Model 43 - 44 - ObjectBox runs as a **secondary data store** alongside Drift. It stores only embedding vectors and the metadata needed to join back to Drift's `SavedPosts`/cached posts. 45 - Drift remains the source of truth for post content. 46 - 47 - ```dart 48 - @Entity() 49 - class EmbeddedPost { 50 - @Id() 51 - int id = 0; 52 - 53 - /// AT URI of the post (e.g. at://did:plc:xxx/app.bsky.feed.post/yyy) 54 - @Unique() 55 - String postUri; 56 - 57 - /// Account DID that saved/liked this post 58 - String accountDid; 59 - 60 - /// 'saved' or 'liked' 61 - String source; 62 - 63 - /// Concatenated searchable text at embedding time 64 - String indexedText; 65 - 66 - /// 384-dimensional embedding vector 67 - @HnswIndex(dimensions: 384, distanceType: VectorDistanceType.cosine) 68 - @Property(type: PropertyType.floatVector) 69 - List<double>? embedding; 70 - 71 - /// When the embedding was generated (for staleness checks) 72 - @Property(type: PropertyType.dateNano) 73 - DateTime embeddedAt; 74 - } 75 - ``` 76 - 77 - ### Embedding Service 78 - 79 - `EmbeddingService` wraps the TFLite interpreter, running in a long-lived background `Isolate` to avoid UI jank. 80 - 81 - **Initialization:** 82 - 83 - 1. App startup → spawn isolate 84 - 2. Isolate loads TFLite model from assets (`assets/models/minilm_l6_v2_int8.tflite`) 85 - 3. Load tokenizer vocabulary (`assets/models/vocab.txt`) -- WordPiece tokenizer, max 256 tokens 86 - 4. Isolate listens on `ReceivePort` for embed requests 87 - 88 - **Embedding a post:** 89 - 90 - 1. Concatenate: `post.text + ' ' + altTexts.join(' ') + ' ' + linkCard?.title + ' ' + linkCard?.description` 91 - 2. Tokenize (WordPiece, pad/truncate to 256 tokens) 92 - 3. Run interpreter: input `[1, 256]` int32 tensor → output `[1, 384]` float32 tensor 93 - 4. L2-normalize the output vector 94 - 5. Return `Float32List` to caller via `SendPort` 95 - 96 - **Error handling:** If model fails to load (corrupt asset, unsupported device), semantic search degrades gracefully to unavailable. A flag `EmbeddingService.isAvailable` gates all UI entry points. 97 - 98 - ### Indexing Strategy 99 - 100 - **On save/like (incremental):** When a post is saved or liked, immediately queue it for embedding. The `EmbeddingService` isolate processes the queue serially. This keeps indexing latency invisible to the user -- most posts embed in <20ms. 101 - 102 - **Backfill (first launch or re-index):** On first enable or after clearing the index, batch-embed all existing saved/liked posts. Process in chunks of 50 with `Future.delayed(Duration.zero)` yielding between chunks to avoid hogging the isolate. Show progress in settings UI ("Indexing: 142/300 posts..."). 103 - 104 - **Staleness:** Posts are immutable on ATProto, so embeddings never go stale. If a post is un-saved or un-liked, remove its `EmbeddedPost` entry. 105 - 106 - **Account isolation:** `EmbeddedPost.accountDid` scopes all queries. On account switch, ObjectBox queries filter by the active account's DID. 107 - 108 - ### Search UX 109 - 110 - **Entry point:** New "Semantic Search" tab in the existing saved posts screen. Two tabs: "All Saved" (existing list) and "Search" (vector search). 111 - 112 - **Search tab layout:** 113 - 114 - - Text field with hint "Search your saved posts..." 115 - - Debounce: 500ms after typing stops 116 - - Results: list of post cards (reuse existing `PostCard` widget), ordered by cosine similarity 117 - - Each result shows a relevance badge (percentage, derived from `1 - cosineDistance`) 118 - - Empty state when no query entered: "Search your saved and liked posts by meaning, not just keywords" 119 - - No results state: "No similar posts found" 120 - - Max results: 20 (configurable in settings) 121 - 122 - **Scope toggle:** Chip row above results: "Saved" / "Liked" / "Both" (default: Both). Implemented as an ObjectBox query condition combined with the vector nearest-neighbor query. 123 - 124 - ### Liked Posts Integration 125 - 126 - Liked posts are not currently persisted locally. To include them in semantic search: 127 - 128 - **New Drift table:** 129 - 130 - ```dart 131 - @DataClassName('LikedPostEntry') 132 - class LikedPosts extends Table { 133 - IntColumn get id => integer().autoIncrement(); 134 - TextColumn get accountDid => text(); 135 - TextColumn get postUri => text(); 136 - TextColumn get postJson => text(); 137 - DateTimeColumn get likedAt => dateTime().withDefault(currentDateAndTime); 138 - 139 - @override 140 - List<String> get customConstraints => ['UNIQUE (account_did, post_uri)']; 141 - } 142 - ``` 143 - 144 - **Sync strategy:** Periodic background sync of `bluesky.feed.getActorLikes(actor:, limit:, cursor:)`. Runs on app foreground (if >5 minutes since last sync) and on manual pull-to-refresh. Fetches newest likes until it hits an already-known URI, then stops. Caps at 1000 stored likes per account (evicts oldest on overflow). 145 - 146 - This is a **Drift migration** (schema version 15). 147 - 148 - ### Settings 149 - 150 - Under "Search" section in settings: 151 - 152 - - **Semantic Search** toggle (default: off) -- enables/disables the feature, triggers backfill on first enable 153 - - **Search scope** -- "Saved only" / "Liked only" / "Both" (default: Both) 154 - - **Index status** -- shows count of indexed posts, "Re-index" button 155 - - **Max results** -- slider, 10-50, default 20 156 - 157 - ### Package Dependencies 158 - 159 - | Package | Version | Purpose | 160 - | ------------------------ | ------- | ---------------------------------------------- | 161 - | `objectbox` | ^5.3.1 | Vector storage + HNSW nearest-neighbor queries | 162 - | `objectbox_flutter_libs` | ^5.3.1 | Platform-specific ObjectBox native libraries | 163 - | `tflite_flutter` | ^0.12.1 | On-device TFLite model inference | 164 - 165 - Build tooling: `objectbox_generator` (build_runner) for code generation. 166 - 167 - ### ObjectBox Integration Notes 168 - 169 - ObjectBox requires its own initialization separate from Drift: 170 - 171 - ```dart 172 - final store = await openStore(directory: join(appDocDir, 'objectbox')); 173 - ``` 174 - 175 - This runs once at app startup (after Drift init). The `Store` instance is provided via the service locator / `RepositoryProvider` tree alongside the existing Drift database. 176 - 177 - ObjectBox's generated `objectbox-model.json` and `objectbox.g.dart` must be committed. Run `dart run build_runner build` after entity changes. 178 - 179 - ### Performance Budget 180 - 181 - | Operation | Target | Notes | 182 - | ------------------------------ | ------ | -------------------------------------- | 183 - | Model load (cold) | <500ms | One-time on app start | 184 - | Single post embedding | <20ms | MiniLM INT8 on mid-range device | 185 - | Batch embed 100 posts | <3s | In background isolate | 186 - | Vector query (1000 vectors) | <5ms | ObjectBox HNSW | 187 - | Vector query (10000 vectors) | <15ms | ObjectBox HNSW | 188 - | Model asset size | ~25 MB | INT8 quantized MiniLM-L6-v2 | 189 - | ObjectBox storage (1000 posts) | ~2 MB | 384 floats x 4 bytes x 1000 + metadata | 190 - 191 - ### Limitations & Future Work 192 - 193 - - **Text-only embeddings.** Image content is captured only via alt text and link card metadata. 194 - A future phase could add image embeddings (MobileNet V3 + separate HNSW index), but that doubles model size and complexity. 195 - - **No cross-account search.** Each account's embeddings are isolated. A "search all accounts" mode could be added later. 196 - - **No re-ranking.** Results are pure cosine similarity. A future improvement could apply BM25 re-ranking on the top-K results for hybrid search. 197 - (Highest priority future update) 198 - - **Liked posts sync is incremental, not complete.** The 1000-like cap means very old likes won't be searchable. 199 - This is a pragmatic trade-off for storage.
-227
docs/specs/phase-8.md
··· 1 - --- 2 - title: Phase 8 Spec 3 - updated: 2026-04-11 4 - --- 5 - 6 - ## Follow Hygiene — Detect & Remove Inactive/Problematic Follows 7 - 8 - Audit the authenticated user's follow list to surface accounts that are 9 - deleted, deactivated, suspended, blocking/blocked-by, hidden by moderation, or 10 - the user's own DID. Present a filterable, selectable list and batch-unfollow in 11 - a single action. 12 - 13 - ### Why 14 - 15 - Users accumulate dead follows over time — accounts get suspended, deactivated, 16 - or start blocking. Bluesky provides no built-in way to audit this. The existing 17 - profile action layer handles individual follow/unfollow, but there is no 18 - batch-audit or batch-unfollow capability. 19 - 20 - ### Data Flow 21 - 22 - ```text 23 - User taps "Clean Follows" 24 - → Paginate com.atproto.repo.listRecords(collection: app.bsky.graph.follow) 25 - → Collect all follow record URIs + subject DIDs 26 - → Batch-hydrate via app.bsky.actor.getProfiles (25 per batch) 27 - → For each batch, classify: 28 - - Missing from response → resolve DID individually → deleted/deactivated/suspended 29 - - Present but viewer.blockedBy → blocked-by (or mutual block if also blocking) 30 - - Present but viewer.blocking/blockingByList → blocking 31 - - Present but labels contain "!hide" → hidden by moderation 32 - - subject DID == own DID → self-follow 33 - → Display categorized results 34 - → User selects accounts → batch delete via com.atproto.repo.applyWrites 35 - ``` 36 - 37 - ### Account Classification 38 - 39 - Reuse the existing `_hydrateProfiles` pattern from `ProfileContextRepository` 40 - — batch `getProfiles`, then per-DID fallback for missing entries. Extend with 41 - follow-specific status classification: 42 - 43 - | Status | Detection | 44 - | ------------- | ------------------------------------------------------------- | 45 - | Deleted | `getProfile` returns "not found" error | 46 - | Deactivated | `getProfile` returns "deactivated" error | 47 - | Suspended | `getProfile` returns "suspended" error | 48 - | Blocked By | `viewer.blockedBy == true` on profile response | 49 - | Blocking | `viewer.blocking != null` or `viewer.blockingByList != null` | 50 - | Mutual Block | Both `blockedBy` and `blocking` are true | 51 - | Hidden | Profile labels contain `val == "!hide"` | 52 - | Self-follow | Subject DID matches authenticated user's DID | 53 - 54 - ### Batch Unfollow 55 - 56 - AT Protocol's `com.atproto.repo.applyWrites` accepts up to 200 operations per 57 - call. The `bluesky` Dart package exposes this as 58 - `atproto.repo.applyWrites(writes: [...])`. 59 - 60 - Each delete operation: 61 - 62 - ```dart 63 - { 64 - '$type': 'com.atproto.repo.applyWrites#delete', 65 - 'collection': 'app.bsky.graph.follow', 66 - 'rkey': rkey, // extracted from the follow record URI 67 - } 68 - ``` 69 - 70 - Chunk selected records into batches of 200 and execute sequentially. Update 71 - local state after each successful batch. 72 - 73 - ### Repository Layer 74 - 75 - **`FollowAuditRepository`** — new file in `lib/features/profile/data/`. 76 - 77 - Depends on the authenticated `Bluesky` client (same injection pattern as 78 - `ProfileActionRepository` and `ProfileContextRepository`). 79 - 80 - **Methods:** 81 - 82 - - `fetchAllFollows()` → paginate `atproto.repo.listRecords(repo: did, 83 - collection: 'app.bsky.graph.follow', limit: 100)` with cursor. Returns 84 - `List<FollowRecord>` (uri, rkey, subjectDid). 85 - - `classifyFollows(List<FollowRecord>, String ownDid)` → batch 86 - `getProfiles` (25/batch, 2 concurrent batches, 500ms delay between groups), 87 - per-DID fallback for missing, return `List<ClassifiedFollow>`. 88 - - `batchUnfollow(List<ClassifiedFollow>)` → `applyWrites` in chunks of 200. 89 - 90 - **Models:** 91 - 92 - ```dart 93 - enum FollowStatus { 94 - deleted, 95 - deactivated, 96 - suspended, 97 - blockedBy, 98 - blocking, 99 - mutualBlock, 100 - hidden, 101 - selfFollow, 102 - } 103 - 104 - class FollowRecord { 105 - final String uri; 106 - final String rkey; 107 - final String subjectDid; 108 - } 109 - 110 - class ClassifiedFollow { 111 - final FollowRecord record; 112 - final String? handle; 113 - final FollowStatus status; 114 - final String statusLabel; 115 - bool selected; 116 - } 117 - ``` 118 - 119 - ### Rate Limiting 120 - 121 - The `bluesky` package's `getProfiles` accepts up to 25 actors. Strategy: 122 - 123 - - Batch size: 25 (API max) 124 - - Concurrent batches: 2 125 - - Inter-group delay: 500ms 126 - - On rate-limit (429) or network error: retry with exponential backoff (1s, 2s, 127 - 4s), max 3 retries per batch, then skip and count as failed 128 - 129 - ### Cubit 130 - 131 - **`FollowAuditCubit`** — new file in `lib/features/profile/cubit/`. 132 - 133 - **States:** 134 - 135 - ```dart 136 - enum FollowAuditStatus { 137 - initial, 138 - fetching, // paginating follow records 139 - classifying, // hydrating + classifying profiles 140 - ready, // results displayed 141 - unfollowing, // batch delete in progress 142 - complete, // unfollow finished 143 - error, 144 - } 145 - 146 - class FollowAuditState { 147 - final FollowAuditStatus status; 148 - final List<ClassifiedFollow> results; 149 - final int totalFollows; 150 - final int progress; // records processed so far 151 - final int failedProfiles; // profiles that couldn't be fetched 152 - final int unfollowedCount; // after batch delete 153 - final String? errorMessage; 154 - final Set<FollowStatus> visibleStatuses; // filter toggles 155 - } 156 - ``` 157 - 158 - **Methods:** 159 - 160 - - `audit()` — fetch all follows, classify, transition through 161 - fetching → classifying → ready. 162 - - `toggleSelection(int index)` — toggle `selected` on a single result. 163 - - `selectAllByStatus(FollowStatus)` / `deselectAllByStatus(FollowStatus)` — 164 - bulk select/deselect by category. 165 - - `toggleVisibility(FollowStatus)` — show/hide a category in the list. 166 - - `confirmUnfollow()` — batch-delete selected, transition to 167 - unfollowing → complete. 168 - 169 - Progress is reported as `progress / totalFollows` during both fetching and 170 - classifying phases. 171 - 172 - ### UI 173 - 174 - **Entry point:** New item in the settings screen under a "Follows" or 175 - "Account Maintenance" section. Also accessible from the profile screen's 176 - overflow menu (three-dot) for the user's own profile. 177 - 178 - **Screen: `FollowAuditScreen`** 179 - 180 - Layout (top to bottom): 181 - 182 - 1. **Header** — "Clean Follows" title, subtitle with follow count once loaded. 183 - 2. **Action bar** — "Scan" button (initial state) → "Unfollow Selected (N)" 184 - button (ready state). Disabled during fetching/classifying/unfollowing. 185 - 3. **Progress indicator** — linear progress bar during fetch/classify. Shows 186 - "Fetching follows: 142/1200" or "Classifying: 300/1200". If failed profiles 187 - > 0, show amber warning text. 188 - 4. **Filter sidebar / chip row** — one toggle per `FollowStatus` category. 189 - Each toggle shows the category label and count. Visibility toggle 190 - (show/hide in list) + "Select All" checkbox per category. On narrow 191 - screens, render as a horizontal scrollable chip row above the list; on 192 - wider screens, render as a sticky sidebar. 193 - 5. **Results list** — each row: checkbox, handle (tappable → profile), DID 194 - (truncated, tappable → copy), status badge. Selected rows have a 195 - destructive-red background tint. Rows hidden by visibility filter are 196 - excluded from the list entirely. 197 - 6. **Summary footer** — "Selected: 12/47" count. After unfollow: 198 - "Unfollowed 12 accounts". 199 - 7. **Empty/complete states** — "No problematic follows found" or 200 - "Unfollowed N account(s)". 201 - 202 - **Styling:** Follows the UI refactor spec — square geometry, uppercase labels, 203 - `outlineVariant` borders, `surfaceContainerLowest` card backgrounds. 204 - 205 - ### Error Handling 206 - 207 - - Network failure during fetch: show error state with retry button. 208 - - Partial profile hydration failure: continue with available data, show count 209 - of failed profiles as a warning (not a blocker). 210 - - `applyWrites` failure on a batch: stop, show error with count of successful 211 - unfollows so far, allow retry for remaining. 212 - - Account not authenticated: guard entry points behind auth state (already 213 - handled by app shell). 214 - 215 - ### Limitations & Future Work 216 - 217 - - **No "inactive" detection.** We'll only detects hard states (deleted, 218 - suspended, blocking). Detecting genuinely inactive accounts (no posts in N 219 - months) would require fetching each account's feed — prohibitively expensive 220 - for large follow lists. Could be added as an opt-in deep scan later. 221 - - **No undo.** Unfollow is destructive. A future version could cache unfollowed 222 - DIDs locally and offer a "re-follow" list for a limited time. 223 - - **Single-account.** Runs against the active account only. Multi-account batch 224 - audit is out of scope. 225 - - **No background execution.** The audit runs in the foreground. For users with 226 - 10k+ follows, this could take 1-2 minutes. A future version could use a 227 - background isolate with notification on completion.
-321
docs/specs/routing.md
··· 1 - --- 2 - title: AppView Routing + Trending (Bluesky + Blacksky + microcosm Fallbacks) 3 - updated: 2026-04-30 4 - --- 5 - 6 - Introduce explicit AppView routing so Lazurite can target: 7 - 8 - 1. Bluesky (`did:web:api.bsky.app#bsky_appview`) 9 - 2. Blacksky (`did:web:api.blacksky.community#bsky_appview`) 10 - 3. microcosm fallbacks for specific degraded paths (identity and backlink enrichments) 11 - 12 - Design goal: route intentionally, fail predictably, and avoid hidden dependence on one provider. 13 - 14 - ## Product Decisions 15 - 16 - 1. Provider selection is chosen on the login screen. 17 - 2. Provider selection can be changed in Settings, but change requires app reset. 18 - 3. Cross-provider fallback (A -> B) is user-controlled (default off). 19 - 4. Slingshot identity fallback is setting-gated. 20 - 5. Provider health/capability probes run at startup, with manual refresh in Settings. 21 - 6. Blacksky is available by default on login/onboarding. 22 - 7. Trending is first-class: Home/Feed includes a Trending action, and app has a dedicated Trending screen. 23 - 24 - ## Current State (Lazurite) 25 - 26 - - OAuth login now resolves account authority first (handle/DID -> PDS -> `authorization_servers`) and only falls back when resolution fails. 27 - - AppView provider routing is implemented for `app.bsky.*` headers/public reads, and `com.atproto.*` bypasses AppView routing. 28 - - Home feed app bar has feed-management action only; no Trending action. 29 - - Router has no `/trending` route. 30 - 31 - ## Research Findings 32 - 33 - ### 1. Routing and proxy expectations 34 - 35 - - `app.bsky.*` should route via user PDS for authenticated requests, with explicit `atproto-proxy` toward selected AppView. 36 - - Public `app.bsky.*` reads can call AppView host directly (`public.api.bsky.app` for Bluesky). 37 - - Clients should not rely on legacy default forwarding behavior. 38 - 39 - ### 2. Verified AppViews and compatibility (live checks, 2026-04-29) 40 - 41 - - Bluesky DID/service: 42 - - `did:web:api.bsky.app#bsky_appview` 43 - - public host: `https://public.api.bsky.app` 44 - - Blacksky DID/service: 45 - - `did:web:api.blacksky.community#bsky_appview` 46 - - public host: `https://api.blacksky.community` 47 - - Both hosts currently respond for: 48 - - `app.bsky.actor.getProfile` 49 - - `app.bsky.unspecced.getTrends` 50 - - `app.bsky.unspecced.getTrendingTopics` 51 - 52 - ### 3. Trending endpoint contract (official lexicons) 53 - 54 - - `app.bsky.unspecced.getTrends` 55 - - params: `limit` (default 10, min 1, max 25) 56 - - output: `trends[]` (`trendView`) 57 - - `app.bsky.unspecced.getTrendingTopics` 58 - - params: `viewer?` DID, `limit` (default 10, min 1, max 25) 59 - - output: `topics[]` + `suggested[]` (`trendingTopic`) 60 - 61 - ### 4. Live provider divergence relevant to UI 62 - 63 - - Bluesky `getTrendingTopics` currently returns both `topics` and non-empty `suggested`. 64 - - Blacksky `getTrendingTopics` currently returns `topics` and often empty `suggested`. 65 - - Link formats differ: 66 - - Bluesky trend links often `/profile/.../feed/...` 67 - - Blacksky trend links often `/topic/<id>` 68 - 69 - ### 5. Other AppViews in ecosystem 70 - 71 - - There are additional self-hosted/experimental AppView implementations in the ecosystem. 72 - - For this phase, treat these as `custom` providers only (advanced path), not default onboarding options, until each candidate is validated for DID/service health and endpoint parity. 73 - 74 - ## Design 75 - 76 - ### AppView provider model 77 - 78 - Add settings-backed provider selection: 79 - 80 - - `bluesky` (default) 81 - - `blacksky` 82 - - `custom` (advanced, validation-gated) 83 - 84 - Provider descriptor: 85 - 86 - ```dart 87 - class AppViewProvider { 88 - final String key; // bluesky, blacksky, custom 89 - final String serviceDid; // did:web:...#bsky_appview 90 - final Uri publicBaseUrl; // public app.bsky host 91 - final Uri entrywayUrl; // login/account entryway 92 - final Uri webBaseUrl; // provider web base for relative trend links 93 - } 94 - ``` 95 - 96 - Built-in defaults: 97 - 98 - - Bluesky: `public.api.bsky.app`, `bsky.social`, `https://bsky.app` 99 - - Blacksky: `api.blacksky.community`, `blacksky.community`, `https://blacksky.community` 100 - 101 - ### Router abstraction 102 - 103 - Introduce `AppViewRouter` as single source of truth: 104 - 105 - - `Map<String, String> appBskyProxyHeaders()` 106 - - `Uri publicEndpoint(String xrpcPath, Map<String, String> query)` 107 - - `Uri entrywayForAuth()` 108 - - `Uri resolveWebLink(String relativeOrAbsolute)` 109 - - `Future<AppViewHealth> probeProvider()` 110 - 111 - ### Request routing policy 112 - 113 - 1. Authenticated `app.bsky.*` 114 - - Route through PDS. 115 - - Set explicit `atproto-proxy` to selected provider DID. 116 - 2. Signed-out/public `app.bsky.*` 117 - - Call selected provider `publicBaseUrl` directly. 118 - 3. `com.atproto.*` 119 - - Never AppView-routed; resolve PDS by DID/handle as normal. 120 - 121 - ### Trending UX and routing 122 - 123 - 1. Home app bar adds `Trending` action button. 124 - - Route target: `/trending`. 125 - 2. Add dedicated `TrendingScreen`. 126 - - Primary data source: `getTrendingTopics(limit=10)`. 127 - - Required enrichment (initial implementation): `getTrends(limit=10)` for richer metadata (actors, postCount, status/category). 128 - - UI sections: 129 - - `Topics` 130 - - `Suggested` (hidden when empty) 131 - 132 - Implementation note: 133 - 134 - - The first shipped Trending screen must join `getTrendingTopics` + `getTrends` data in one load flow. 135 - - If `getTrends` fails and cross-provider fallback is disabled or unavailable, render `topics` with a degraded metadata state and explicit non-blocking error indicator. 136 - 137 - Deterministic join contract: 138 - 139 - - Build a stable join key for both `topics[]` and `trends[]` before matching. 140 - - Key precedence (in order): 141 - 142 - 1. Parsed link key (preferred): 143 - - `/topic/<id>` -> `topic:<id>` 144 - - `/profile/<actor>/feed/<rkey>` -> `feed:<actor>:<rkey>` 145 - 2. Normalized topic string fallback: 146 - - lowercase 147 - - trim 148 - - collapse internal whitespace 149 - - drop leading `#` 150 - - Matching algorithm: 151 - 1. Try exact parsed-link-key match. 152 - 2. If absent, try normalized-topic-string match. 153 - 3. If multiple trend candidates match, pick the candidate with newest `startedAt`. 154 - 4. If still tied, pick lexicographically smallest `link` for deterministic output. 155 - 5. If no match, keep topic row and mark metadata as unavailable. 156 - 157 - Trending UI state contract: 158 - 159 - - `topics` load success + `trends` load success: 160 - - render fully enriched rows (actors/postCount/status/category when present). 161 - - `topics` load success + `trends` degraded/failure: 162 - - render topic rows without metadata fields. 163 - - show non-blocking banner/chip: `Metadata temporarily unavailable`. 164 - - keep row navigation actions enabled. 165 - - `topics` failure: 166 - - render blocking error state for Trending screen. 167 - 168 - 3. Trend row actions: 169 - - Use provider-aware `resolveWebLink` for relative links. 170 - - If link maps to supported internal route, deep-link internally. 171 - - If unsupported, open external browser to provider `webBaseUrl + link`. 172 - 173 - 4. Link parsing safety: 174 - - Never assume one provider link format. 175 - - Support at least: 176 - - `/profile/<actor>/feed/<rkey>` 177 - - `/topic/<id>` 178 - - Unknown path formats degrade to external open. 179 - 180 - ### Fallback policy (defensive) 181 - 182 - 1. Try selected provider. 183 - 2. If cross-provider fallback setting is ON, then on transient read failures (`429`, `5xx`, timeout, DNS): 184 - - Try alternate built-in provider for read-only public endpoints. 185 - 3. For non-AppView enrichments: 186 - - Backlink/index enrichments: Constellation. 187 - - Identity fallback: Slingshot `resolveMiniDoc` only when enabled. 188 - 4. Log fallback reason/provider and apply endpoint-level circuit breaker. 189 - 190 - Do not fallback across write operations. 191 - 192 - Opt-in/trust boundary notes: 193 - 194 - - Cross-provider AppView fallback is OFF by default and only applies to public read endpoints. 195 - - Slingshot identity fallback is OFF by default and only used when primary handle resolution is degraded. 196 - - Slingshot fallback data is treated as a recovery hint (DID/PDS seed), not as authority for write routing. 197 - 198 - ### Capability gating 199 - 200 - Track endpoint support per provider to avoid blind retries: 201 - 202 - - `app.bsky.actor.getProfile` 203 - - `app.bsky.feed.getPostThread` 204 - - `app.bsky.unspecced.getTrends` 205 - - `app.bsky.unspecced.getTrendingTopics` 206 - 207 - ### Reset UX contract (recommended) 208 - 209 - Best contract for provider switching: 210 - 211 - 1. User selects provider in Settings. 212 - 2. Blocking confirmation sheet: 213 - - `Apply and restart now` 214 - - `Cancel` 215 - - Copy: user stays signed in; no local DB wipe. 216 - 3. On confirm, perform soft restart: 217 - - Persist provider first. 218 - - Stop new requests and cancel in-flight work. 219 - - Tear down and rebuild app-level DI/blocs/services. 220 - - Return to bootstrap and rehydrate from persisted state. 221 - 222 - This avoids mixed in-memory routing state while preserving session continuity. 223 - 224 - ### State safety requirements 225 - 226 - 1. `AppViewRouter` is the only runtime source of provider state. 227 - 2. Long-lived repos/blocs must not cache provider independently. 228 - 3. Provider switch blocks new requests until rebuild completes. 229 - 4. Use routing epoch/version so stale pre-reset responses are dropped. 230 - 231 - ### Login-time persistence ordering 232 - 233 - 1. Persist login-screen provider selection before any auth/network request. 234 - 2. Disable login submission while persistence is in-flight. 235 - 3. Construct auth/network clients only after provider setting loads at bootstrap. 236 - 237 - ### OAuth authority selection (account vs selected AppView) 238 - 239 - 1. Selected AppView controls content routing (`app.bsky.*`) and web link resolution. 240 - 2. OAuth authorization host must come from account authority metadata: 241 - - resolve handle/DID to PDS 242 - - fetch `/.well-known/oauth-protected-resource` 243 - - use `authorization_servers` issuer host first 244 - 3. Fallback chain when metadata lookup fails: 245 - - resolved PDS host 246 - - `bsky.social` 247 - - selected-provider entryway 248 - - final default fallback 249 - 4. Do not force OAuth host to selected AppView, because users can choose one AppView for reading while their account is hosted/authenticated elsewhere. 250 - 251 - ### Health probes 252 - 253 - - Run once at startup. 254 - - Expose manual `Refresh Provider Health` in Settings. 255 - - No periodic background probes in this phase. 256 - 257 - ## Adversarial checks 258 - 259 - 1. Provider advertises `#bsky_appview` but partially implements endpoints. 260 - 2. Semantics diverge even when endpoint exists (moderation, trends, topic links). 261 - 3. Docs can lag live infrastructure. 262 - 4. Trend link paths can drift by provider/version. 263 - 264 - Mitigation: 265 - 266 - - Startup capability probes. 267 - - Endpoint-level fallback gates. 268 - - Structured logs + provider failure counters. 269 - - Defensive link resolver with safe external fallback. 270 - 271 - ## Testing Strategy 272 - 273 - ### Unit 274 - 275 - - Provider selection/normalization. 276 - - Login-time provider persistence before auth call. 277 - - Bootstrap ordering (no client creation before provider load). 278 - - Routing epoch stale-response guard. 279 - - Header injection (`atproto-proxy`). 280 - - Fallback + circuit-breaker transitions. 281 - - Trending limit clamping (1..25). 282 - - Trend link parsing and resolver fallback behavior. 283 - - Deterministic topic/trend join precedence and tie-break behavior. 284 - - Topic-string normalization behavior for join fallback. 285 - 286 - ### Integration 287 - 288 - - `/trending` route reachable from Home button. 289 - - Bluesky and Blacksky trending fetch paths. 290 - - Empty `suggested` renders cleanly. 291 - - `topics` success + `trends` failure renders degraded metadata indicator with usable navigation. 292 - - Forced primary failure with fallback ON/OFF. 293 - - Provider switch soft restart fully rebuilds routing consumers. 294 - 295 - ### Regression 296 - 297 - - `com.atproto.*` unaffected. 298 - - OAuth/App Password flows keep correct PDS behavior. 299 - - Provider switch does not mix stale in-memory routing state. 300 - 301 - ## Non-goals (this phase) 302 - 303 - - Auto-switch providers for write operations. 304 - - Unvalidated public listing of arbitrary third-party AppViews in onboarding. 305 - - Replacing existing Constellation features. 306 - 307 - ## Sources 308 - 309 - - <https://docs.bsky.app/docs/advanced-guides/api-directory> 310 - - <https://docs.bsky.app/blog/2025-protocol-roadmap-spring> 311 - - <https://api.bsky.app/.well-known/did.json> 312 - - <https://api.blacksky.community/.well-known/did.json> 313 - - <https://raw.githubusercontent.com/bluesky-social/atproto/main/lexicons/app/bsky/unspecced/getTrends.json> 314 - - <https://raw.githubusercontent.com/bluesky-social/atproto/main/lexicons/app/bsky/unspecced/getTrendingTopics.json> 315 - - <https://raw.githubusercontent.com/bluesky-social/atproto/main/lexicons/app/bsky/unspecced/defs.json> 316 - - <https://raw.githubusercontent.com/bluesky-social/atproto/main/lexicons/app/bsky/unspecced/getTrendsSkeleton.json> 317 - - <https://www.microcosm.blue/> 318 - - <https://constellation.microcosm.blue/> 319 - - <https://slingshot.microcosm.blue/> 320 - - <https://tangled.org/why.bsky.team/konbini> 321 - - <https://sdk.blue/>
-259
docs/specs/typeahead.md
··· 1 - --- 2 - title: Configurable Typeahead 3 - updated: 2026-04-25 4 - --- 5 - 6 - ## Summary 7 - 8 - Replace the current single-source typeahead with a configurable system that 9 - supports multiple backends. Two integration points: 10 - 11 - 1. **Login** - server/handle resolution during OAuth sign-in 12 - 2. **Search** - actor autocomplete in search, jump-to-profile, list member 13 - add, and starter pack member add 14 - 15 - ## Typeahead backends 16 - 17 - ### 1. Bluesky Official (default) 18 - 19 - Existing endpoint already used by `SearchRepository.searchActorsTypeahead`. 20 - 21 - - **Endpoint:** `app.bsky.actor.searchActorsTypeahead` 22 - - **SDK:** `_bluesky.actor.searchActorsTypeahead(q:, limit:)` 23 - - **Auth:** Required (session token) 24 - - **Response:** `List<ProfileViewBasic>` - `did`, `handle`, `displayName`, 25 - `avatar`, `labels` 26 - - **Rate limit:** Standard Bluesky XRPC limits 27 - 28 - ### 2. waow.tech Community Typeahead 29 - 30 - Community-run drop-in replacement. Useful for unauthenticated contexts (login) 31 - and as a faster/broader index. 32 - 33 - - **Endpoint:** `GET https://typeahead.waow.tech/xrpc/app.bsky.actor.searchActorsTypeahead` 34 - - **Params:** `q` (required), `limit` (optional, 1–100, default 10) 35 - - **Headers:** `X-Client: lazurite` (identifies app for traffic stats) 36 - - **Auth:** None required 37 - - **Response:** Same `actors` array shape as Bluesky, minus `viewer` field 38 - - **Rate limit:** 60 req/min per IP, 60s result cache 39 - - **Discovery:** Auto-indexes via Jetstream monitoring; on-demand backfill for 40 - unknown accounts 41 - - **Moderation:** Respects Bluesky `!hide`/`!takedown`/`!suspend`/`spam` labels; 42 - filters slur handles 43 - 44 - ### Response normalisation 45 - 46 - Both backends return actors in a compatible shape. The waow.tech response omits 47 - `viewer` (requires auth). Normalise into a common model: 48 - 49 - ```dart 50 - class TypeaheadResult { 51 - final String did; 52 - final String handle; 53 - final String? displayName; 54 - final String? avatarUrl; 55 - final List<Label> labels; 56 - } 57 - ``` 58 - 59 - Parse from `ProfileViewBasic` (Bluesky) or raw JSON (waow.tech) via a factory. 60 - 61 - ## Configuration 62 - 63 - ### Settings model 64 - 65 - Add a `typeaheadProvider` field to the settings table: 66 - 67 - | Key | Type | Values | Default | 68 - | -------------------- | ------ | ---------------------- | --------- | 69 - | `typeahead_provider` | string | `bluesky`, `community` | `bluesky` | 70 - 71 - Exposed via `SettingsCubit` as `state.typeaheadProvider`. 72 - 73 - ### Settings UI 74 - 75 - Add a "Typeahead Provider" option in Settings under a "Search" section: 76 - 77 - - **Bluesky** - official endpoint, requires login 78 - - **Community (waow.tech)** - faster, works pre-login, community-run 79 - 80 - Show a brief description for each option. The community option notes it's a 81 - third-party service. 82 - 83 - ## TypeaheadRepository 84 - 85 - Central abstraction that delegates to the configured backend: 86 - 87 - ```dart 88 - class TypeaheadRepository { 89 - TypeaheadRepository({ 90 - required Bluesky? bluesky, 91 - required String provider, 92 - ModerationService? moderationService, 93 - }); 94 - 95 - Future<List<TypeaheadResult>> search({ 96 - required String query, 97 - int limit = 10, 98 - }); 99 - } 100 - ``` 101 - 102 - When `provider == 'bluesky'`: 103 - 104 - - Delegates to `bluesky.actor.searchActorsTypeahead` 105 - - Passes moderation headers 106 - - Filters via `ModerationService` 107 - 108 - When `provider == 'community'`: 109 - 110 - - HTTP GET to `https://typeahead.waow.tech/xrpc/app.bsky.actor.searchActorsTypeahead` 111 - - Uses the existing `http` package (already a dependency) 112 - - Adds `X-Client: lazurite` header 113 - - Parses `actors` array from JSON response 114 - - Applies local moderation filtering (labels are included in response) 115 - 116 - ### Fallback 117 - 118 - If the community endpoint fails (timeout, rate limit, 5xx), fall back to the 119 - Bluesky endpoint when a session is available. Log the fallback via `AppLogger`. 120 - 121 - ## Integration: Login Screen 122 - 123 - ### Current state 124 - 125 - The login screen has a `TextFormField` for handle/DID entry with no 126 - autocomplete. Users must type their full handle. 127 - 128 - ### New behaviour 129 - 130 - Add typeahead suggestions below the handle field as the user types: 131 - 132 - 1. User types ≥ 2 characters 133 - 2. Debounce 300ms 134 - 3. Call `TypeaheadRepository.search` - **always uses community backend** on the 135 - login screen since no session exists yet (override the setting for this context) 136 - 4. Show results in a `ListView` overlay anchored below the text field 137 - 5. Each result shows: avatar, display name, handle 138 - 6. Tapping a result fills the handle field and triggers the OAuth flow 139 - 140 - ### Widget: `TypeaheadTextField` 141 - 142 - Reusable widget combining `TextFormField` + overlay suggestions: 143 - 144 - ```dart 145 - class TypeaheadTextField extends StatefulWidget { 146 - final TextEditingController controller; 147 - final TypeaheadRepository repository; 148 - final ValueChanged<TypeaheadResult> onSelected; 149 - final InputDecoration? decoration; 150 - final int debounceMs; 151 - final int minChars; 152 - final int limit; 153 - } 154 - ``` 155 - 156 - Uses `OverlayEntry` positioned via `LayerLink` + `CompositedTransformFollower` 157 - for correct placement. The overlay follows the text field and adapts to keyboard 158 - presence. 159 - 160 - ### Login-specific flow 161 - 162 - On the login screen, the `TypeaheadRepository` is created without a `Bluesky` 163 - instance and forces `provider: 'community'`. This allows handle discovery 164 - before authentication. 165 - 166 - ## Integration: Search Screen 167 - 168 - ### Current state 169 - 170 - `SearchBloc` has `TypeaheadRequested` / `TypeaheadResultsLoaded` events that 171 - call `SearchRepository.searchActorsTypeahead` on debounced text input. Results 172 - are stored in `state.typeaheadActors` and shown in the search screen. 173 - 174 - ### Changes 175 - 176 - Replace the direct `SearchRepository.searchActorsTypeahead` call in 177 - `SearchBloc._onTypeaheadRequested` with `TypeaheadRepository.search`. The bloc 178 - receives a `TypeaheadRepository` instead of calling `SearchRepository` for 179 - typeahead. 180 - 181 - The typeahead provider setting determines the backend. Users who prefer the 182 - community index get it everywhere (search, jump-to-profile, list member add, 183 - starter pack member add). 184 - 185 - ### Existing typeahead consumers 186 - 187 - All these currently call `SearchRepository.searchActorsTypeahead` and should 188 - migrate to `TypeaheadRepository`: 189 - 190 - | Location | Context | 191 - | ---------------------------------- | ---------------------------- | 192 - | `SearchBloc._onTypeaheadRequested` | Search screen autocomplete | 193 - | Jump-to-profile dialog | Search screen FAB | 194 - | List member add screen | `searchActorsTypeahead` call | 195 - | Starter pack member search | Create/edit starter pack | 196 - 197 - ## TypeaheadCubit 198 - 199 - Shared cubit for typeahead state, usable by any screen: 200 - 201 - ```dart 202 - class TypeaheadCubit extends Cubit<TypeaheadState> { 203 - TypeaheadCubit({required TypeaheadRepository repository}); 204 - 205 - void onQueryChanged(String query); // debounced 206 - void clear(); 207 - } 208 - 209 - class TypeaheadState { 210 - final List<TypeaheadResult> results; 211 - final bool isLoading; 212 - final String? error; 213 - } 214 - ``` 215 - 216 - This replaces the typeahead-related events in `SearchBloc`, keeping search 217 - concerns separate from typeahead concerns. 218 - 219 - ## Debouncing & rate limiting 220 - 221 - - 300ms debounce on text changes (existing pattern in `SearchBloc`) 222 - - Cancel in-flight requests when a new query arrives 223 - - Community backend: 60 req/min limit - debounce alone keeps usage well under 224 - this (user would need to change input 60 times in a minute past debounce) 225 - - Empty/whitespace queries return empty results immediately (no API call) 226 - 227 - ## Moderation 228 - 229 - Bluesky backend: moderation headers are included; server-side filtering applies. 230 - Results also pass through local `ModerationService.shouldFilterProfileBasicInList`. 231 - 232 - Community backend: response includes labels. Apply local moderation filtering 233 - using the same `ModerationService` logic. The community service already hides 234 - `!hide`/`!takedown`/`!suspend` server-side, but local filtering catches 235 - user-specific label preferences. 236 - 237 - ## Bloc architecture 238 - 239 - ```text 240 - SettingsCubit.typeaheadProvider 241 - 242 - 243 - TypeaheadRepository ──► Bluesky SDK (authenticated) 244 - │ HTTP client (community, unauthenticated) 245 - 246 - TypeaheadCubit ──► TypeaheadState { results, isLoading, error } 247 - 248 - 249 - TypeaheadTextField (Login, Search, Lists, Starter Packs) 250 - ``` 251 - 252 - ## Limitations 253 - 254 - - Community backend has no `viewer` field - cannot show follow status in 255 - typeahead results (degrade gracefully: hide follow badge) 256 - - Community backend caches for 60s - very recent handle changes may lag 257 - - Login typeahead cannot fall back to Bluesky (no session) 258 - - DID entry (not handles) bypasses typeahead entirely - typeahead is handle/name 259 - search only
-17
docs/tasks/phase-1.md
··· 1 - # Phase 1 Milestones 2 - 3 - ## M0 — Project Scaffolding 4 - 5 - Completed [2026-03-16](../../CHANGELOG.md#2026-03-16) 6 - 7 - ## M1 — Authentication 8 - 9 - Completed [2026-03-16](../../CHANGELOG.md#2026-03-16) 10 - 11 - ## M2 — Profile Rendering 12 - 13 - Completed [2026-03-17](../../CHANGELOG.md#2026-03-17) 14 - 15 - ## M3 — Settings & Theming 16 - 17 - Completed [2026-03-16](../../CHANGELOG.md#2026-03-16)
-17
docs/tasks/phase-2.md
··· 1 - # Phase 2 Milestones 2 - 3 - ## M4 — Logging 4 - 5 - Completed [2026-03-18](../../CHANGELOG.md#2026-03-18) 6 - 7 - ## M5 — Feeds 8 - 9 - Completed [2026-03-18](../../CHANGELOG.md#2026-03-18) 10 - 11 - ## M6 — Search 12 - 13 - Completed [2026-03-19](../../CHANGELOG.md#2026-03-19) 14 - 15 - ## M7 — Dev Tools (PDS Explorer) 16 - 17 - Completed [2026-03-18](../../CHANGELOG.md#2026-03-18)
-17
docs/tasks/phase-3.md
··· 1 - # Phase 3 Milestones 2 - 3 - ## M8 — Post Composition 4 - 5 - Completed [2026-03-18](../../CHANGELOG.md#2026-03-18) 6 - 7 - ## M9 — Notifications 8 - 9 - Completed [2026-03-19](../../CHANGELOG.md#2026-03-19) 10 - 11 - ## M10 — Post & Profile Actions 12 - 13 - Completed [2026-03-17](../../CHANGELOG.md#2026-03-17) 14 - 15 - ## M11 — Saved Posts 16 - 17 - Completed [2026-03-17](../../CHANGELOG.md#2026-03-17)
-33
docs/tasks/phase-4.md
··· 1 - # Phase 4 Milestones 2 - 3 - ## M12 — Direct Messages 4 - 5 - Completed [2026-03-19](../../CHANGELOG.md#2026-03-19) 6 - 7 - ## M13 — Media Playback & Download 8 - 9 - Completed [2026-03-19](../../CHANGELOG.md#2026-03-19) 10 - 11 - ## M14 — Account Switching 12 - 13 - Completed [2026-04-12](../../CHANGELOG.md#2026-04-12) 14 - 15 - ## M15 — Offline Reading & Network Resilience 16 - 17 - Complete [2026-03-29](../../CHANGELOG.md#2026-03-29) 18 - 19 - ## M16 — Jump to Profile 20 - 21 - Completed [2026-03-19](../../CHANGELOG.md#2026-03-19) 22 - 23 - ## M17 — Labelers & Content Moderation 24 - 25 - Completed [2026-03-21](../../CHANGELOG.md#2026-03-21) 26 - 27 - ## M18 — Lists 28 - 29 - Completed [2026-03-21](../../CHANGELOG.md#2026-03-21) 30 - 31 - ## M19 — Starter Packs 32 - 33 - Completed [2026-03-22](../../CHANGELOG.md#2026-03-22)
-88
docs/tasks/phase-7.md
··· 1 - --- 2 - title: Phase 7 Task Breakdown 3 - updated: 2026-04-29 4 - --- 5 - 6 - # Phase 7 Milestones 7 - 8 - ## M26 - Semantic Search for Saved & Liked Posts 9 - 10 - ### Core 11 - 12 - #### ObjectBox Setup 13 - 14 - - [x] Add `objectbox`, `objectbox_flutter_libs` to `pubspec.yaml`; add `objectbox_generator` to dev deps 15 - - [x] `EmbeddedPost` entity - `postUri` (unique), `accountDid`, `source` (saved/liked), `indexedText`, `embedding` (384D float vector, HNSW cosine index), `embeddedAt` 16 - - [x] Run `build_runner` to generate `objectbox.g.dart` and `objectbox-model.json` 17 - - [x] `ObjectBoxStore` singleton - `openStore()` at app startup (after Drift init), expose via `RepositoryProvider` 18 - - [x] `EmbeddingRepository` - CRUD operations on `EmbeddedPost`: `upsert`, `deleteByUri`, `queryByAccount`, `countByAccount` 19 - 20 - #### TFLite Embedding Service 21 - 22 - - [x] Add `tflite_flutter` to `pubspec.yaml` 23 - - [x] Bundle `minilm_l6_v2_int8.tflite` and `vocab.txt` as Flutter assets 24 - - [x] `WordPieceTokenizer` - load vocab, tokenize text, pad/truncate to 256 tokens, return `List<int>` 25 - - [x] `EmbeddingService` - long-lived background `Isolate` with `ReceivePort`/`SendPort` message passing 26 - - [x] `EmbeddingService.initialize()` - spawn isolate, load TFLite model + tokenizer in isolate 27 - - [x] `EmbeddingService.embed(String text)` - send text to isolate, receive `Float32List[384]`, L2-normalize 28 - - [x] `EmbeddingService.isAvailable` - flag gating UI entry points, false if model fails to load 29 - - [x] `EmbeddingService.dispose()` - close isolate and interpreter 30 - - [x] `PostTextExtractor` - concatenate post text + image alt texts + link card title/description into a single searchable string 31 - 32 - #### Liked Posts Sync 33 - 34 - - [x] `LikedPosts` Drift table - `id`, `accountDid`, `postUri`, `postJson`, `likedAt`; unique constraint on `(account_did, post_uri)` 35 - - [x] Drift migration v15 - add `liked_posts` table 36 - - [x] `LikedPostsRepository` - `syncLikes(accountDid)`: call `bluesky.feed.getActorLikes(actor:, limit:100, cursor:)`, paginate until hitting known URI or 1000 cap, upsert new entries 37 - - [x] `LikedPostsRepository.getLikedPosts(accountDid, {limit, offset})` - paginated query 38 - - [x] `LikedPostsRepository.removeLike(accountDid, postUri)` - delete entry 39 - - [x] Eviction: drop oldest entries when count exceeds 1000 per account 40 - - [x] Documentation update: move development information from README.md to a top-level DEVELOPMENT.md. 41 - Should be updated to reflect new architecture and patterns. 42 - 43 - #### Indexing Pipeline 44 - 45 - - [x] `SemanticIndexer` - orchestrates embedding + storage for new posts 46 - - [x] `indexPost(postUri, postJson, accountDid, source)` - extract text, embed, upsert `EmbeddedPost` 47 - - [x] `removePost(postUri)` - delete `EmbeddedPost` entry 48 - - [x] `backfill(accountDid)` - batch-embed all un-indexed saved + liked posts, chunks of 50, yield between chunks 49 - - [x] `backfillProgress` stream - emits `(int completed, int total)` for UI progress display 50 - - [x] Hook into `SavedPostsRepository.savePost()` - queue new save for indexing 51 - - [x] Hook into `LikedPostsRepository.syncLikes()` - queue newly synced likes for indexing 52 - - [x] Hook into unsave/unlike - remove from `EmbeddedPost` 53 - 54 - #### Vector Search 55 - 56 - - [x] `SemanticSearchRepository` - depends on `EmbeddingService`, `EmbeddingRepository` 57 - - [x] `search(query, accountDid, {source, maxResults})` - embed query, run `nearestNeighborsF32`, filter by `accountDid` and optional `source`, return `List<SemanticSearchResult>` 58 - - [x] `SemanticSearchResult` model - `postUri`, `score` (cosine similarity as percentage), `source` (saved/liked) 59 - - [x] Join results back to Drift `SavedPosts`/`LikedPosts` to hydrate full post JSON for display 60 - 61 - ### Cubit 62 - 63 - - [x] `SemanticSearchCubit` - `search(query)` with 500ms debounce, `setScope(source)`, `clearResults()` 64 - - [x] `SemanticSearchState` - `status` (initial/searching/loaded/error/unavailable), `results`, `query`, `scope` (saved/liked/both) 65 - - [x] `LikedPostsSyncCubit` - `sync()` triggers like sync, exposes sync progress 66 - - [x] `SemanticIndexCubit` - exposes `backfillProgress`, `indexedCount`, `reindex()` action 67 - 68 - ### UI 69 - 70 - #### Semantic Search Tab 71 - 72 - - [x] Saved posts screen - add "Search" tab alongside existing "All Saved" tab 73 - - [x] Search text field with hint "Search your saved posts..." 74 - - [x] Scope toggle chips: "Saved" / "Liked" / "Both" (default: Both) 75 - - [x] Results list - reuse `PostCard`, ordered by similarity score 76 - - [x] Relevance badge on each result (percentage) 77 - - [x] Empty state (no query): "Search your saved and liked posts by meaning, not just keywords" 78 - - [x] No results state: "No similar posts found" 79 - - [x] Unavailable state: shown when `EmbeddingService.isAvailable` is false, with explanation 80 - 81 - #### Settings 82 - 83 - - [x] Settings screen - new "Search" section 84 - - [x] "Semantic Search" toggle (default: off) - enables feature, triggers backfill on first enable 85 - - [x] "Search scope" dropdown - Saved only / Liked only / Both 86 - - [x] "Index status" tile - shows indexed post count, "Re-index" button 87 - - [x] "Max results" slider - 10 to 50, default 20 88 - - [x] Backfill progress indicator - "Indexing: 142/300 posts..." shown during backfill
-12
docs/tasks/phase-8.md
··· 1 - --- 2 - title: Phase 8 Task Breakdown 3 - updated: 2026-04-27 4 - --- 5 - 6 - ## M27 - Follow Hygiene: Detect & Remove Inactive/Problematic Follows 7 - 8 - Completed [2026-04-11](../../CHANGELOG.md#2026-04-11) 9 - 10 - ## M28 - Micro-Animations with flutter_animate 11 - 12 - Completed [2026-04-28](../../CHANGELOG.md#2026-04-28)
-6
docs/tasks/routing.md
··· 1 - --- 2 - title: AppView Routing + Trending Milestones 3 - updated: 2026-04-30 4 - --- 5 - 6 - Completed [2026-04-30](../../CHANGELOG.md#2026-04-30)
-3
docs/tasks/typeahead.md
··· 1 - # Typeahead Milestones 2 - 3 - Completed [2026-04-29](../../CHANGELOG.md#2026-04-29)