Skip to content

stormlightlabs/lazurite

Repository files navigation

Lazurite

Cross-platform Bluesky client built with Flutter and Dart using Material You (M3) design.

Features

  • Auth + identity resolution
  • Home timeline + threads + profiles + search + notifications
  • Compose (with media) + likes/reposts/replies
  • DMs (read + write): convo list, messages, send, read state
  • Local features:
    • Smart folders (local-only "feeds" built from cached posts)
    • Local post drafts (offline compose; later publish)

Maybes

  • Firehose ingestion, custom feed generator hosting, heavy moderation tooling

Architecture

Stack

Flutter + Dart, Drift (SQLite), Dio (networking), Riverpod (state), go_router (navigation)

Data flow

  • Authenticated requests → user's PDS
  • Public reads → public.api.bsky.app
  • Chat → PDS with service proxy header

Storage

  • Drift tables for posts, profiles, timeline, notifications, DMs, smart folders, and drafts
  • Auth secrets in secure storage (not Drift).

Local Dev

Use just targets:

  • just format - runs dart format lib test
  • just lint - proxies flutter analyze
  • just test - executes the full flutter test suite
  • just gen - invokes dart run build_runner build --delete-conflicting-outputs
  • just check - performs format + lint + test before commits

For local smoke runs:

flutter pub get
flutter run -d android
flutter run -d chrome
flutter run -d ios
Endpoint Routing & Host Strategy

Host Selection

Host selection is a first-class concern:

  1. Unauthenticated, public reads (fast path)

    • Base URL: https://public.api.bsky.app
    • Use for: browsing without login (profiles, public feeds, threads, search)
  2. Authenticated requests (normal logged-in mode)

    • Base URL: the user's PDS (discovered from identity resolution)
    • Why: authenticated requests are "usually made to the user's PDS, with automatic service proxying"
  3. Service proxying (chat + some services)

    • Make the request to the user's PDS, and set the service proxy header to target the downstream service
    • Bluesky chat endpoints require proxying to did:web:api.bsky.chat

Endpoint Map by Feature

Identity + Sessions

Endpoint Method Host Auth Notes
com.atproto.identity.resolveHandle GET PDS None Resolve handle → DID
com.atproto.identity.resolveDid GET PDS None Resolve DID → DID document
com.atproto.identity.resolveIdentity GET PDS None Resolve handle or DID → DID doc + verified handle
com.atproto.server.createSession POST PDS None Legacy login (app passwords)
com.atproto.server.getSession GET PDS User Check current session

Core Reading

Endpoint Method Host Auth Notes
app.bsky.feed.getTimeline GET PDS / AppView User Home timeline
app.bsky.actor.getProfile GET PDS / AppView None / User Actor profile
app.bsky.feed.getAuthorFeed GET PDS / AppView None Actor feed (posts + reposts)
app.bsky.feed.getPostThread GET PDS / AppView None / User Thread view
app.bsky.feed.getPosts GET PDS / AppView None Hydrate posts by AT-URI
app.bsky.feed.searchPosts GET PDS / AppView None / User Search posts
app.bsky.feed.getLikes GET PDS / AppView None Who liked a post
app.bsky.feed.getRepostedBy GET PDS / AppView None Who reposted a post
app.bsky.graph.getFollowers GET PDS / AppView None Get followers
app.bsky.graph.getFollows GET PDS / AppView None Get follows

Notifications

Endpoint Method Host Auth Notes
app.bsky.notification.listNotifications GET PDS User List notifications
app.bsky.notification.getUnreadCount GET PDS User Unread count

Writing (Posts, Likes, Reposts, Follows)

Endpoint Method Host Auth Notes
com.atproto.repo.createRecord POST PDS User Create a record (generic write)
com.atproto.repo.putRecord POST PDS User Update a record
com.atproto.repo.deleteRecord POST PDS User Delete a record
com.atproto.repo.applyWrites POST PDS User Batch writes (atomic-ish)
com.atproto.repo.uploadBlob POST PDS User Upload media blob

Collections used with createRecord/putRecord/deleteRecord:

  • Post: app.bsky.feed.post
  • Like: app.bsky.feed.like
  • Repost: app.bsky.feed.repost
  • Follow: app.bsky.graph.follow

DMs (Bluesky Chat via PDS proxy)

Endpoint Method Host Auth Proxy Notes
chat.bsky.convo.listConvos GET PDS User did:web:api.bsky.chat Convo list (inbox)
chat.bsky.convo.getMessages GET PDS User did:web:api.bsky.chat Fetch messages in a convo
chat.bsky.convo.sendMessage POST PDS User did:web:api.bsky.chat Send message
chat.bsky.convo.updateRead POST PDS User did:web:api.bsky.chat Mark convo read
chat.bsky.convo.acceptConvo POST PDS User did:web:api.bsky.chat Accept convo request

Implementation note: The "proxying header" mechanism is standardized in XRPC HTTP specs; implement it once in Dio as an interceptor/RequestOptions hook, then reuse.

Local-only Features

"Smart" folders

  • Definition: locally stored queries over cached timeline/threads + local labels
  • Example folder rules: "Unread", "From follows only", "Mentions + keyword", "Media only", "Saved"
  • Inputs (network): whatever you choose to cache (timeline/author feed/thread/etc.)
  • Output: purely local projection; no server-side "custom feed generator" required

Local post drafts

  • Stored in Drift only; not published until you call repo.createRecord/applyWrites
Routing

Why go_router?

  • URL-based navigation (deep links, shareable routes, desktop parity)
  • Nested navigation with persistent tab state
  • Auth gating via redirect()
  • Path + query parameters, nested routes, and redirection support

Router Shape

Use StatefulShellRoute.indexedStack for bottom tabs so each tab preserves state (scroll position, nested stacks).

Tabs (StatefulShellRoute branches):

  • Home
  • Search
  • Notifications
  • DMs
  • Profile (Me)

Rules:

  • Put "detail" routes (thread, profile, convo) as children of the branch where the user is coming from, so the bottom bar stays visible
  • Put "global modals" (compose, settings) on the root navigator so they present above tabs

Route Map

Path Description Notes
/splash (optional)
/login Login screen
/settings Settings screen
/compose Compose modal Root modal
/drafts Local-only list
/drafts/:draftId Edit draft
/home Home tab
/home/t/:postKey Thread from home
/home/u/:did Profile from home
/search Search tab
/search?q=<query> Search results Query params
/search/t/:postKey Thread from search
/search/u/:did Profile from search
/notifs Notifications tab
/notifs/t/:postKey Thread from notifs
/notifs/u/:did Profile from notifs
/dms DMs tab
/dms/c/:convoId Convo detail
/me Profile (me) tab
/me/u/:did Profile view

About :postKey: Don't put raw at://did/... in the path. Encode it as a URL-safe token (base64url(atUri) or percent-encode into a query param like /home/t?uri=...).

Auth Gating + Reactive Redirects (Riverpod)

go_router redirects are not automatically reactive; you must provide a refreshListenable if you want auth changes to trigger redirects reliably.

Pattern (opinionated, reliable):

  • Auth state lives in Riverpod (e.g., AuthController Notifier)
  • Create a small Listenable bridge for GoRouter.refreshListenable:
    • ValueNotifier<int> "tick"
    • ref.listen(authProvider, (_, __) => tick.value++)
  • In redirect(), read auth state and return:
    • unauthenticated → /login for protected routes
    • authenticated → redirect away from /login to /home

Protected vs public routes:

  • Public allowed: /home (read-only), /search, viewing threads/profiles
  • Auth required: /notifs, /dms, /compose publish, follow/like/repost actions

Deep Linking (Android/iOS)

  • Use go_router URL paths that mirror shareable content
  • Configure Android App Links + iOS Universal Links so https://<yourdomain>/... can open the app to a matching route
  • Prefer path params for required IDs; query params for optional filters/search
State Management

Why Riverpod?

  • Dependency injection (Dio clients, DB, repositories)
  • View-model state (timeline paging, thread loading, draft autosave, DM outbox)
  • Flutter's architecture guide recommends separation of concerns and "Views + ViewModels" with repositories in the data layer
  • Riverpod recommends (Async)Notifier/(Async)NotifierProvider for state that changes via user interaction

Provider Types

Type Example Use Case
Provider<T> Provider<AppDb> Singletons / resources (DB, Dio, secure storage, endpoint map)
Provider<T> Provider<TimelineRepository> Repositories (data layer)
AsyncNotifierProvider<T, S> AsyncNotifierProvider<TimelineController, TimelineState> ViewModels with I/O (network/db)
NotifierProvider<T, S> NotifierProvider<ComposerController, ComposerState> ViewModels with sync state (text fields + local draft)

Dependencies (singletons / resources):

  • Provider<AppDb> (Drift DB)
  • Provider<Dio> dioPublic
  • Provider<Dio> dioPds
  • Provider<AuthSessionStore> (secure storage-backed)
  • Provider<EndpointMap> (single source of routing truth)

Repositories (data layer):

  • Provider<TimelineRepository>
  • Provider<ThreadRepository>
  • Provider<DmRepository>
  • Provider<DraftRepository>

ViewModels (presentation layer):

  • AsyncNotifierProvider<TimelineController, TimelineState>
  • AsyncNotifierProvider<ThreadController, ThreadState>
  • NotifierProvider<ComposerController, ComposerState> (sync + autosave)
  • AsyncNotifierProvider<DmInboxController, DmInboxState>
  • AsyncNotifierProvider<DmConvoController, DmConvoState>

Rule of thumb:

  • Use AsyncNotifier when you do I/O (network/db) or have "loading/error/data"
  • Use Notifier for purely local synchronous state (text fields + local draft), while persisting via a repository

File Placement

lib/src/app/providers.dart
  - global providers for env, dio, db, secure storage, endpoint map

lib/src/infrastructure/...
  - pure Dart classes: XrpcClient, interceptors, DAOs (no Riverpod here)

lib/src/features/<feature>/presentation/<feature>_controller.dart
  - Notifier/AsyncNotifier implementation
  - holds paging cursors, refresh logic, optimistic UI, etc.

lib/src/features/<feature>/domain/usecases/
  - optional: thin usecases called by controllers, especially for complex flows
    (publish draft pipeline, DM outbox flush)

Testing Conventions

  • Unit test controllers with ProviderContainer:
    • Override repositories with fakes
    • Assert state transitions: loading → data, optimistic updates, retry behavior
  • DAO tests run against an in-memory/temporary Drift database
  • Network tests mock Dio adapters (or replace XrpcClient in providers)
Database Schema (Drift)

Principles

  • Use stable keys (DID, at:// URIs)
  • Store raw JSON payloads for forward compatibility, plus minimal indexed columns
  • Keep auth secrets out of Drift; store them in secure storage

Core Cache Tables

Table Primary Key Purpose
accounts did User accounts (handle, pdsUrl, active)
profiles did Profile data (handle, json, updatedAt)
posts uri Post data (cid, authorDid, textPreview, indexedAt, json)
timeline_items (feedKey, postUri) Timeline cache (sortKey, cursor)
notifications id Notifications (indexedAt, seenAt, json)

DMs

Table Primary Key Purpose
dm_convos convoId Conversation metadata (lastMessageAt, unreadCount, muted, json)
dm_members (convoId, memberDid) Conversation members
dm_messages (convoId, messageId) Messages (sentAt, senderDid, status, json)
dm_outbox localId Outgoing messages queue (convoId, payloadJson, attemptCount, retryAt)

Smart Folders

Table Primary Key Purpose
smart_folders folderId Folder metadata (name, sortMode, createdAt, updatedAt)
smart_folder_rules ruleId Folder rules (folderId, type, payloadJson)
smart_folder_items (folderId, postUri) Optional materialization (score, insertedAt)

Drafts

Table Primary Key Purpose
drafts draftId Draft metadata (createdAt, updatedAt, status, text, facetsJson, replyToUri, quoteUri, embedJson)
draft_media (draftId, localMediaId) Draft media (pathOrUri, mime, size, altText, uploadCid, status)

References

Credits

Typography inspiration from Anisota by Dame.is (@dame.is).

Witchsky (code)

About

No description, website, or topics provided.

Resources

Contributing

Stars

Watchers

Forks

Releases

No releases published

Sponsor this project

  •  

Packages

No packages published