diff --git a/assets/flutter.svg b/assets/flutter.svg new file mode 100644 index 0000000..1e58034 --- /dev/null +++ b/assets/flutter.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/gh.svg b/assets/gh.svg new file mode 100644 index 0000000..1577a09 --- /dev/null +++ b/assets/gh.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/logo.svg b/assets/logo.svg new file mode 100644 index 0000000..b933902 --- /dev/null +++ b/assets/logo.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/tangled.svg b/assets/tangled.svg new file mode 100644 index 0000000..d9ce621 --- /dev/null +++ b/assets/tangled.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/typography.svg b/assets/typography.svg new file mode 100644 index 0000000..906284a --- /dev/null +++ b/assets/typography.svg @@ -0,0 +1,3 @@ + + + diff --git a/docs/specs/phase-3.md b/docs/specs/phase-3.md index 3d025ba..5ff5ccb 100644 --- a/docs/specs/phase-3.md +++ b/docs/specs/phase-3.md @@ -119,123 +119,157 @@ Display an unread count badge on the Notifications nav bar item. Poll Build a `NotificationBloc` with events: `NotificationsRequested`, `NotificationsRefreshed`, `NotificationsPageLoaded`, `NotificationsMarkedRead`. -## Direct Messages +## Post & Profile Actions -DMs use the `chat.bsky.*` lexicon namespace. The DM feature has two views: a -conversation list and a message thread. +All post and profile interactions use the AT Protocol record model. 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`. Muting is a server-side procedure call with +no persistent record. ### API -| Endpoint | Purpose | -| -------------------------------------- | ----------------------------- | -| `chat.bsky.convo.listConvos` | Paginated conversation list | -| `chat.bsky.convo.getConvo` | Single conversation metadata | -| `chat.bsky.convo.getMessages` | Paginated messages in a convo | -| `chat.bsky.convo.sendMessage` | Send a message | -| `chat.bsky.convo.deleteMessageForSelf` | Delete a message locally | -| `chat.bsky.convo.muteConvo` | Mute a conversation | -| `chat.bsky.convo.unmuteConvo` | Unmute a conversation | -| `chat.bsky.convo.updateRead` | Mark conversation as read | -| `chat.bsky.convo.getLog` | Polling for new events | +| Endpoint | Purpose | +| ------------------------------------- | --------------------------------------- | +| `com.atproto.repo.createRecord` | Create like/repost/follow/block records | +| `com.atproto.repo.deleteRecord` | Delete like/repost/follow/block records | +| `app.bsky.graph.muteActor` | Mute an account | +| `app.bsky.graph.unmuteActor` | Unmute an account | +| `com.atproto.moderation.createReport` | Report a post or account | -### Conversation List +### Like -`listConvos` returns conversations sorted by last message time. Each convo -includes `id`, `members` (array of `profileViewBasic`), `lastMessage`, -`unreadCount`, `muted`. +Collection: `app.bsky.feed.like`. Record contains a `subject` (RepoStrongRef +with the post's AT-URI and CID) and `createdAt`. -Filter conversations into two tabs: **Primary** (accepted) and **Requests** -(conversations the user has not yet responded to). A conversation is a -"request" if the user has never sent a message in it. +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. -### Message Thread +The `PostView.viewer.like` field is non-null when the current user has liked +the post. Use this to drive the filled/outlined heart icon state. -`getMessages` returns paginated `messageView` objects. Each message has `id`, -`text`, `sender` (DID), `sentAt`. Messages are displayed in a standard chat -bubble layout — the current user's messages right-aligned, others left-aligned. +### Repost -Support long-press to copy individual messages. Provide a "Copy All" option in -the conversation overflow menu to copy the full thread. +Collection: `app.bsky.feed.repost`. Record structure is identical to like — +`subject` (RepoStrongRef) + `createdAt`. -### Sending Messages +To un-repost, extract the rkey from `viewer.repost` and delete the record. -`sendMessage` takes `convoId` and `message` (object with `text`). To start a -new conversation, the app calls `chat.bsky.convo.getConvoForMembers` with the -target DID(s) — this returns an existing convo or creates a new one. +The `PostView.viewer.repost` field is non-null when the current user has +reposted. Use this for the repost icon state. -| Endpoint | Purpose | -| ------------------------------------ | --------------------- | -| `chat.bsky.convo.getConvoForMembers` | Get or create a convo | +### Follow -Build a `ConvoListBloc` with events: `ConvosRequested`, `ConvosRefreshed`, -`ConvoMuted`, `ConvoUnmuted`. +Collection: `app.bsky.graph.follow`. Record contains `subject` (the target +user's DID as a string) and `createdAt`. -Build a `MessageBloc` with events: `MessagesRequested`, `MessagesPageLoaded`, -`MessageSent`, `MessageDeleted`, `ConvoMarkedRead`. +To unfollow, extract the rkey from `viewer.following` and delete the record. -## Account Switching +Viewer state fields on profiles: -Support multiple authenticated accounts with full data isolation. The -`accounts` table (from Phase 1) already supports multiple rows keyed by DID. +- `viewer.following` — non-null AT-URI if the current user follows this profile +- `viewer.followedBy` — non-null AT-URI if this profile follows the current user -### Active Account +### Mute -Store the active account DID in the Drift `settings` table under key -`active_account_did`. On launch, read this value and restore the session for -that account. +Mute and unmute are procedure calls (not record creation): -### Data Isolation +- `app.bsky.graph.muteActor` — input: `{ actor: DID }` +- `app.bsky.graph.unmuteActor` — input: `{ actor: DID }` -All user-scoped tables must include an `account_did` FK column. Queries always -filter by the active account's DID. Tables requiring this constraint: +Both return empty responses. The `viewer.muted` boolean on profiles reflects +the current mute state. Muted accounts' posts are still fetched but should be +visually de-emphasised or filtered in the UI based on user preference. -- `drafts` -- `saved_posts` -- `search_history` (Phase 2) -- `cached_posts` (add `account_did` if not present) +### Block -### Switching Flow +Collection: `app.bsky.graph.block`. Record contains `subject` (the target +user's DID) and `createdAt`. -1. User opens account switcher (bottom sheet or settings). -2. Selects a different account. -3. App updates `active_account_did` in settings. -4. All Blocs receive a `AccountSwitched` event and reload their state for the - new account. -5. If the selected account's tokens are expired, attempt silent refresh. If - refresh fails, navigate to login. +To unblock, extract the rkey from `viewer.blocking` and delete the record. -### Adding Accounts +Viewer state fields on profiles: -"Add Account" triggers the same OAuth flow from Phase 1. On success, a new row -is inserted into `accounts`. The new account becomes the active account. +- `viewer.blocking` — non-null AT-URI if the current user blocks this profile +- `viewer.blockedBy` — boolean, true if this profile blocks the current user -Build an `AccountSwitcherCubit` that exposes the list of accounts and the -active DID. +When a user is blocked, their posts should be hidden from feeds and threads. +Display a "You have blocked this user" placeholder in their profile view. -## Offline Reading +### Report -The app should render cached data when the network is unavailable. This builds -on the `cached_posts` and `cached_profiles` tables from Phase 1. +Reports use `com.atproto.moderation.createReport` with two subject types: -### Cache Strategy +| Subject Type | Usage | Fields | +| --------------- | ---------------------- | ------------- | +| `RepoStrongRef` | Report a specific post | `uri` + `cid` | +| `RepoRef` | Report an account | `did` | -Cache the last-fetched page of each feed (timeline, pinned generators) in Drift -as serialised JSON. On launch or feed switch, display cached data immediately, -then fetch fresh data in the background. If the fetch fails, keep showing the -cache with a "You're offline" banner. +Report reasons (from `com.atproto.moderation.defs`): -### Offline Indicators +| Reason | Description | +| ------------------ | --------------------------------- | +| `reasonSpam` | Spam or unsolicited content | +| `reasonViolation` | Violates community guidelines | +| `reasonMisleading` | Misleading or deceptive content | +| `reasonSexual` | Unwanted sexual content | +| `reasonRude` | Harassment or rude behaviour | +| `reasonOther` | Other (requires text explanation) | -- A persistent banner at the top of the screen when connectivity is lost. -- Disable actions that require network (compose, like, repost, follow) and show - a tooltip explaining why. -- Notifications and DM screens show an empty state with "No connection" when - offline and no cached data exists. +The report dialog should present the reason picker and an optional free-text +description field. Submitting returns a report ID for confirmation. -### Network Detection +### Optimistic Updates -Use the **connectivity_plus** package to monitor network state changes. Expose -connectivity as a stream via a `ConnectivityCubit` that all screens observe. +All toggle actions (like, repost, follow, mute, block) should use optimistic +UI updates: + +1. Immediately update the local state (icon, count, button label). +2. Fire the API call in the background. +3. On success, reconcile with the server response (update the viewer URI). +4. On failure, roll back the local state and show a snackbar error. + +Build a `PostActionCubit` that manages per-post action state (like, repost, +save). It accepts the initial `ViewerState` from the post and exposes +toggleable methods. + +Build a `ProfileActionCubit` that manages per-profile action state (follow, +mute, block). It accepts the initial `ViewerState` from the profile and +exposes toggleable methods. + +### Post Action Bar + +The post action bar appears below every post and contains four buttons: + +| Button | Icon | Tap action | Long-press | +| ------ | ------ | ------------- | ----------------- | +| Reply | chat | Open compose | — | +| Repost | repeat | Toggle repost | Quote post option | +| Like | heart | Toggle like | — | +| Share | share | Share sheet | — | + +The bookmark (save) icon is placed in the post overflow menu alongside +"Report" and "Copy link". + +Like and repost counts are displayed next to their respective icons. Counts +update optimistically. The repost long-press opens a bottom sheet with +"Repost" and "Quote Post" options. + +### Profile Action Buttons + +The profile header shows a primary action button based on the relationship: + +| State | Button label | Tap action | +| ---------------- | ------------ | -------------- | +| Not following | "Follow" | Create follow | +| Following | "Following" | Unfollow sheet | +| Blocked by them | — | No button | +| You blocked them | "Unblock" | Delete block | + +The profile overflow menu (three-dot icon) contains: Mute / Unmute, Block / +Unblock, Report, Copy DID, Share profile. + +Mute and block actions should show a confirmation dialog before proceeding. ## Saved Posts @@ -264,16 +298,3 @@ profile screen or settings. 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). - -## Jump to Profile - -Add a floating action button on the search screen. Tapping it opens a dialog -with a text field for entering a handle. Use -`app.bsky.actor.searchActorsTypeahead` to provide autocomplete suggestions as -the user types. Selecting a result or pressing enter navigates to that user's -profile screen. - -| Endpoint | Purpose | -| -------------------------------------- | ------------------- | -| `app.bsky.actor.searchActorsTypeahead` | Handle autocomplete | -| `app.bsky.actor.getProfile` | Full profile fetch | diff --git a/docs/specs/phase-4.md b/docs/specs/phase-4.md new file mode 100644 index 0000000..c7df020 --- /dev/null +++ b/docs/specs/phase-4.md @@ -0,0 +1,329 @@ +# Phase 4 + +## Direct Messages + +DMs use the `chat.bsky.*` lexicon namespace. The DM feature has two views: a +conversation list and a message thread. + +### API + +| Endpoint | Purpose | +| -------------------------------------- | ----------------------------- | +| `chat.bsky.convo.listConvos` | Paginated conversation list | +| `chat.bsky.convo.getConvo` | Single conversation metadata | +| `chat.bsky.convo.getMessages` | Paginated messages in a convo | +| `chat.bsky.convo.sendMessage` | Send a message | +| `chat.bsky.convo.deleteMessageForSelf` | Delete a message locally | +| `chat.bsky.convo.muteConvo` | Mute a conversation | +| `chat.bsky.convo.unmuteConvo` | Unmute a conversation | +| `chat.bsky.convo.updateRead` | Mark conversation as read | +| `chat.bsky.convo.getLog` | Polling for new events | + +### Conversation List + +`listConvos` returns conversations sorted by last message time. Each convo +includes `id`, `members` (array of `profileViewBasic`), `lastMessage`, +`unreadCount`, `muted`. + +Filter conversations into two tabs: **Primary** (accepted) and **Requests** +(conversations the user has not yet responded to). A conversation is a +"request" if the user has never sent a message in it. + +### Message Thread + +`getMessages` returns paginated `messageView` objects. Each message has `id`, +`text`, `sender` (DID), `sentAt`. Messages are displayed in a standard chat +bubble layout — the current user's messages right-aligned, others left-aligned. + +Support long-press to copy individual messages. Provide a "Copy All" option in +the conversation overflow menu to copy the full thread. + +### Sending Messages + +`sendMessage` takes `convoId` and `message` (object with `text`). To start a +new conversation, the app calls `chat.bsky.convo.getConvoForMembers` with the +target DID(s) — this returns an existing convo or creates a new one. + +| Endpoint | Purpose | +| ------------------------------------ | --------------------- | +| `chat.bsky.convo.getConvoForMembers` | Get or create a convo | + +Build a `ConvoListBloc` with events: `ConvosRequested`, `ConvosRefreshed`, +`ConvoMuted`, `ConvoUnmuted`. + +Build a `MessageBloc` with events: `MessagesRequested`, `MessagesPageLoaded`, +`MessageSent`, `MessageDeleted`, `ConvoMarkedRead`. + +## Account Switching + +Support multiple authenticated accounts with full data isolation. The +`accounts` table (from Phase 1) already supports multiple rows keyed by DID. + +### Active Account + +Store the active account DID in the Drift `settings` table under key +`active_account_did`. On launch, read this value and restore the session for +that account. + +### Data Isolation + +All user-scoped tables must include an `account_did` FK column. Queries always +filter by the active account's DID. Tables requiring this constraint: + +- `drafts` +- `saved_posts` +- `search_history` (Phase 2) +- `cached_posts` (add `account_did` if not present) + +### Switching Flow + +1. User opens account switcher (bottom sheet or settings). +2. Selects a different account. +3. App updates `active_account_did` in settings. +4. All Blocs receive a `AccountSwitched` event and reload their state for the + new account. +5. If the selected account's tokens are expired, attempt silent refresh. If + refresh fails, navigate to login. + +### Adding Accounts + +"Add Account" triggers the same OAuth flow from Phase 1. On success, a new row +is inserted into `accounts`. The new account becomes the active account. + +Build an `AccountSwitcherCubit` that exposes the list of accounts and the +active DID. + +## Offline Reading + +The app should render cached data when the network is unavailable. This builds +on the `cached_posts` and `cached_profiles` tables from Phase 1. + +### Cache Strategy + +Cache the last-fetched page of each feed (timeline, pinned generators) in Drift +as serialised JSON. On launch or feed switch, display cached data immediately, +then fetch fresh data in the background. If the fetch fails, keep showing the +cache with a "You're offline" banner. + +### Offline Indicators + +- A persistent banner at the top of the screen when connectivity is lost. +- Disable actions that require network (compose, like, repost, follow) and show + a tooltip explaining why. +- Notifications and DM screens show an empty state with "No connection" when + offline and no cached data exists. + +### Network Detection + +Use the **connectivity_plus** package to monitor network state changes. Expose +connectivity as a stream via a `ConnectivityCubit` that all screens observe. + +## Jump to Profile + +Add a floating action button on the search screen. Tapping it opens a dialog +with a text field for entering a handle. Use +`app.bsky.actor.searchActorsTypeahead` to provide autocomplete suggestions as +the user types. Selecting a result or pressing enter navigates to that user's +profile screen. + +| Endpoint | Purpose | +| -------------------------------------- | ------------------- | +| `app.bsky.actor.searchActorsTypeahead` | Handle autocomplete | +| `app.bsky.actor.getProfile` | Full profile fetch | + +## Labelers & Content Moderation + +Labelers are independent services that produce metadata labels about content +and accounts. Users subscribe to labelers and configure how each label type +affects their experience. The `bluesky` Dart package includes a built-in +moderation decision engine that handles label interpretation. + +### Architecture Overview + +```text +User Preferences ──► ModerationOpts ──► moderatePost() ──► ModerationDecision +Label Definitions ─┘ moderateProfile() └─► getUI(context) + moderateNotification() └─► ModerationUI + ├─ filters + ├─ blurs + ├─ alerts + └─ informs +``` + +### API + +| Endpoint | Purpose | +| ------------------------------- | ----------------------------------- | +| `app.bsky.labeler.getServices` | Fetch labeler details by DID | +| `app.bsky.actor.getPreferences` | Read labeler subscriptions + prefs | +| `app.bsky.actor.putPreferences` | Write labeler subscriptions + prefs | + +### Labeler Subscriptions + +Users subscribe to up to 20 labelers. Subscriptions are stored as a +`labelersPref` entry in the user's preferences. Each entry contains a labeler +DID. The official Bluesky moderation labeler is always active and does not +count against the 20-labeler limit. + +On every XRPC request that returns content (feed, thread, profile, search, +notifications), include the `atproto-accept-labelers` HTTP header with a +comma-separated list of subscribed labeler DIDs. The AppView fetches labels +from those labelers and attaches them to the response. + +### Label Data Model + +A label is a lightweight annotation attached to content or an account: + +| Field | Type | Description | +| ----- | --------- | --------------------------------------------------- | +| `src` | string | DID of the labeler that created the label | +| `uri` | string | AT-URI of the target resource (or DID for accounts) | +| `cid` | string? | Optional CID for a specific version of the target | +| `val` | string | Label value identifier (e.g. "porn", "spam") | +| `neg` | bool? | If true, negates (retracts) a previous label | +| `cts` | datetime | Creation timestamp | +| `exp` | datetime? | Expiration timestamp | + +### Label Behaviour Definitions + +Each label value is defined by three axes that determine its UI effect: + +| Axis | Values | Description | +| ---------------- | -------------------------- | -------------------------------- | +| `blurs` | `content`, `media`, `none` | What gets blurred | +| `severity` | `inform`, `alert`, `none` | Badge type (neutral vs warning) | +| `defaultSetting` | `ignore`, `warn`, `hide` | Default user-configurable action | + +Global (protocol-defined) label values include: + +| Label | Blurs | Default | Notes | +| ---------------- | ------- | ------- | ------------------------------- | +| `!hide` | content | hide | Non-configurable, no override | +| `!warn` | content | warn | Non-configurable, click-through | +| `porn` | media | hide | Adult, 18+ required | +| `sexual` | media | warn | Adult, 18+ required | +| `graphic-media` | media | warn | Adult, 18+ required | +| `nudity` | media | ignore | Not 18+ restricted | +| `dmca-violation` | content | hide | Non-configurable | +| `doxxing` | content | hide | Non-configurable | + +Labelers may also define custom label values with localised names and +descriptions via `LabelValueDefinition`. + +### Self-Labels + +Authors can embed `selfLabels` directly in their posts and profiles. Only +global label values are valid as self-labels. Self-labels are treated as if +the author is the label source and follow the same behaviour rules. + +### Moderation Decision Pipeline + +Use the `bluesky` package's moderation engine for all content display: + +1. **Build `ModerationOpts`** from user preferences: + - `adultContentEnabled` — boolean from preferences + - `labels` — map of label value → preference (`ignore` / `warn` / `hide`) + - `labelers` — list of subscribed labeler DIDs + - `labelDefs` — map of labeler DID → list of custom `InterpretedLabelValueDefinition` + +2. **Run moderation** on every piece of content before display: + - `moderatePost(subject, opts)` for posts + - `moderateProfile(subject, opts)` for profiles + - `moderateNotification(subject, opts)` for notifications + +3. **Apply `ModerationUI`** via `decision.getUI(context)` for each display + context: + - `contentList` — post in a feed or search results + - `contentView` — post in a thread view + - `contentMedia` — images/media within a post + - `avatar` — profile avatar + - `profileList` — profile in a list + - `profileView` — full profile screen + +The `ModerationUI` object contains: + +- `filters` — content should be removed from the list entirely +- `blurs` — content should be placed behind a click-through overlay +- `alerts` — show a warning badge (negative connotation) +- `informs` — show an informational badge (neutral) +- `noOverride` — blur cannot be dismissed by the user + +### Content Label Preferences + +Users configure per-label visibility via `contentLabelPref` entries in +preferences. Each entry specifies: + +| Field | Description | +| ------------ | ------------------------------------------- | +| `labelerDid` | Scope to a specific labeler (null = global) | +| `label` | The label value string | +| `visibility` | `ignore`, `warn`, `hide`, or `show` | + +Labels with `adultOnly: true` in their definition require the user to have +`adultContentEnabled` set to true. If adult content is disabled, these labels +always apply as `hide` regardless of user preference. + +### Rendering Rules + +**Blur overlay**: When `blurs` is non-empty, render a semi-transparent overlay +with the label name and a "Show" button. If `noOverride` is true, omit the +"Show" button — the content cannot be revealed. + +**Alert badge**: When `alerts` is non-empty, show a warning icon with the +label name below the content or next to the profile name. + +**Inform badge**: When `informs` is non-empty, show an info icon with the +label name. Use a neutral colour (not red/warning). + +**Filtering**: When `filters` is non-empty, remove the content from the +current list view entirely. Do not render a placeholder. + +**Media blur**: When the `contentMedia` context has blurs, blur only images +and embedded media while leaving the post text visible. + +**Avatar blur**: When the `avatar` context has blurs, show a generic +placeholder avatar. + +### ModerationService + +Build a `ModerationService` that: + +1. Loads labeler subscriptions and label preferences from user preferences on + login and account switch. +2. Fetches labeler details (`getServices` with `detailed: true`) for all + subscribed labelers to obtain custom label definitions. +3. Caches label definitions in a Drift `labeler_cache` table for offline use. +4. Constructs `ModerationOpts` and exposes it as a stream that updates when + preferences change. +5. Provides convenience methods: `moderatePost()`, `moderateProfile()`, + `moderateNotification()`. + +### Labeler Cache Table + +| Column | Type | Notes | +| --------------- | -------- | -------------------------------- | +| `labeler_did` | text | PK, DID of the labeler | +| `policies_json` | text | Serialised `LabelerViewDetailed` | +| `fetched_at` | datetime | When the data was last refreshed | + +Refresh the cache when the user opens the labeler management screen or when a +subscribed labeler's data is older than 24 hours. + +### Labeler Management UI + +The labeler management screen (accessible from Settings) shows: + +- A list of subscribed labelers, each showing: creator avatar, display name, + description, and number of label definitions. +- Tapping a labeler opens a detail screen showing all label values the labeler + publishes, with the user's current preference (ignore/warn/hide) for each. +- A toggle for subscribing/unsubscribing to the labeler. +- An "Adult content" toggle at the top of the settings screen that gates all + 18+ label preferences. + +### Header Integration + +The XRPC client must be modified to include the `atproto-accept-labelers` +header on all outgoing requests. The header value is a comma-separated list of +labeler DIDs from the user's `labelersPref`. This should be set once on login +and updated whenever preferences change. diff --git a/docs/tasks/phase-3.md b/docs/tasks/phase-3.md index 64a025b..978722b 100644 --- a/docs/tasks/phase-3.md +++ b/docs/tasks/phase-3.md @@ -25,50 +25,27 @@ - [ ] Mark as read via `updateSeen` when notifications screen opens - [ ] Tap notification to navigate to relevant post or profile -## M10 — Direct Messages - -- [ ] Conversation list screen via `chat.bsky.convo.listConvos` with pagination -- [ ] `ConvoListBloc` — events: `ConvosRequested`, `ConvosRefreshed`, `ConvoMuted`, `ConvoUnmuted` -- [ ] Primary / Requests tab filtering on conversation list -- [ ] Message thread screen via `chat.bsky.convo.getMessages` with pagination -- [ ] `MessageBloc` — events: `MessagesRequested`, `MessagesPageLoaded`, `MessageSent`, `MessageDeleted`, `ConvoMarkedRead` -- [ ] Chat bubble layout — current user right-aligned, others left-aligned -- [ ] Send messages via `chat.bsky.convo.sendMessage` -- [ ] New conversation via `chat.bsky.convo.getConvoForMembers` -- [ ] Long-press to copy individual messages, overflow menu "Copy All" for full thread -- [ ] Mute / unmute conversations -- [ ] Mark conversation as read via `chat.bsky.convo.updateRead` - -## M11 — Account Switching - -- [ ] `AccountSwitcherCubit` exposing account list and active DID -- [ ] Account switcher bottom sheet UI — list accounts with avatars and handles -- [ ] Store `active_account_did` in Drift `settings` table -- [ ] Drift migration: add `account_did` column to `cached_posts` if not present -- [ ] All user-scoped queries filter by active account DID -- [ ] Broadcast `AccountSwitched` event to all Blocs on switch -- [ ] "Add Account" button triggers OAuth flow, inserts new `accounts` row -- [ ] Silent token refresh on account switch; navigate to login on failure - -## M12 — Offline Reading & Network Resilience - -- [ ] `ConnectivityCubit` via **connectivity_plus** — expose network state stream -- [ ] Cache last-fetched feed page as serialised JSON in Drift -- [ ] Display cached data immediately on launch, refresh in background -- [ ] "You're offline" banner when connectivity is lost -- [ ] Disable network-dependent actions (compose, like, repost, follow) when offline with tooltip -- [ ] Notifications and DM screens show "No connection" empty state when offline with no cache - -## M13 — Saved Posts +## M10 — Post & Profile Actions + +- [ ] `PostActionRepository` — like, repost, delete via `com.atproto.repo.createRecord` / `deleteRecord` +- [ ] `PostActionCubit` — optimistic state updates for like / repost toggle with rollback on failure +- [ ] Like toggle: create `app.bsky.feed.like` record or delete by rkey; update `viewer.like` and `likeCount` +- [ ] Repost toggle: create `app.bsky.feed.repost` record or delete by rkey; update `viewer.repost` and `repostCount` +- [ ] Post action bar UI — like, repost, reply, share buttons with animated state transitions +- [ ] `ProfileActionRepository` — follow, mute, block, report +- [ ] `ProfileActionCubit` — optimistic follow/mute/block state with rollback +- [ ] Follow toggle: create `app.bsky.graph.follow` record or delete by rkey; update `viewer.following` +- [ ] Mute toggle via `app.bsky.graph.muteActor` / `unmuteActor`; update `viewer.muted` +- [ ] Block toggle: create `app.bsky.graph.block` record or delete by rkey; update `viewer.blocking` +- [ ] Profile action buttons: Follow / Following / Mute / Block in profile header and overflow menu +- [ ] Report dialog: reason picker + optional description, submit via `com.atproto.moderation.createReport` +- [ ] Report for both posts (RepoStrongRef subject) and accounts (RepoRef subject) +- [ ] Confirmation dialog before mute / block actions +- [ ] Thread muting via `app.bsky.feed.threadgate` awareness (show muted-thread indicator) + +## M11 — Saved Posts - [ ] Drift migration: add `saved_posts` table (id, account_did, post_uri, post_json, saved_at) with unique constraint on (account_did, post_uri) - [ ] `SavedPostsCubit` — read/write saved posts, expose stream of saved URIs for icon state - [ ] Bookmark icon on post action bar — toggle saved state - [ ] Saved posts list screen accessible from profile or settings - -## M14 — Jump to Profile - -- [ ] Floating action button on search screen -- [ ] Handle input dialog with autocomplete via `searchActorsTypeahead` -- [ ] Navigate to profile screen on selection or enter -- [ ] Update bottom navigation to include Notifications and Messages tabs (5-tab layout) diff --git a/docs/tasks/phase-4.md b/docs/tasks/phase-4.md new file mode 100644 index 0000000..5bb21c8 --- /dev/null +++ b/docs/tasks/phase-4.md @@ -0,0 +1,61 @@ +# Phase 4 Milestones + +## M12 — Direct Messages + +- [ ] Conversation list screen via `chat.bsky.convo.listConvos` with pagination +- [ ] `ConvoListBloc` — events: `ConvosRequested`, `ConvosRefreshed`, `ConvoMuted`, `ConvoUnmuted` +- [ ] Primary / Requests tab filtering on conversation list +- [ ] Message thread screen via `chat.bsky.convo.getMessages` with pagination +- [ ] `MessageBloc` — events: `MessagesRequested`, `MessagesPageLoaded`, `MessageSent`, `MessageDeleted`, `ConvoMarkedRead` +- [ ] Chat bubble layout — current user right-aligned, others left-aligned +- [ ] Send messages via `chat.bsky.convo.sendMessage` +- [ ] New conversation via `chat.bsky.convo.getConvoForMembers` +- [ ] Long-press to copy individual messages, overflow menu "Copy All" for full thread +- [ ] Mute / unmute conversations +- [ ] Mark conversation as read via `chat.bsky.convo.updateRead` + +## M13 — Account Switching + +- [ ] `AccountSwitcherCubit` exposing account list and active DID +- [ ] Account switcher bottom sheet UI — list accounts with avatars and handles +- [ ] Store `active_account_did` in Drift `settings` table +- [ ] Drift migration: add `account_did` column to `cached_posts` if not present +- [ ] All user-scoped queries filter by active account DID +- [ ] Broadcast `AccountSwitched` event to all Blocs on switch +- [ ] "Add Account" button triggers OAuth flow, inserts new `accounts` row +- [ ] Silent token refresh on account switch; navigate to login on failure + +## M14 — Offline Reading & Network Resilience + +- [ ] `ConnectivityCubit` via **connectivity_plus** — expose network state stream +- [ ] Cache last-fetched feed page as serialised JSON in Drift +- [ ] Display cached data immediately on launch, refresh in background +- [ ] "You're offline" banner when connectivity is lost +- [ ] Disable network-dependent actions (compose, like, repost, follow) when offline with tooltip +- [ ] Notifications and DM screens show "No connection" empty state when offline with no cache + +## M15 — Jump to Profile + +- [ ] Floating action button on search screen +- [ ] Handle input dialog with autocomplete via `searchActorsTypeahead` +- [ ] Navigate to profile screen on selection or enter +- [ ] Update bottom navigation to include Notifications and Messages tabs (5-tab layout) + +## M16 — Labelers & Content Moderation + +- [ ] Fetch user's labeler subscriptions from preferences via `app.bsky.actor.getPreferences` (`labelersPref`) +- [ ] Include subscribed labeler DIDs in `atproto-accept-labelers` header on all XRPC requests +- [ ] `ModerationService` — wraps the `bluesky` package's `moderatePost`, `moderateProfile`, `moderateNotification` functions +- [ ] Run moderation decisions on all displayed posts and profiles +- [ ] Apply `ModerationUI` results: filter, blur, alert, inform per display context (contentList, contentView, contentMedia, avatar, profileList, profileView) +- [ ] Blur overlay on posts/media with click-through "Show content" button +- [ ] Warning badges on profiles and posts for alert/inform labels +- [ ] Content filtering — remove posts with `filter` decisions from feed and notification lists +- [ ] Labeler management screen: list subscribed labelers via `app.bsky.labeler.getServices` +- [ ] Subscribe / unsubscribe to labelers by updating `labelersPref` via `putPreferences` +- [ ] Per-label preference configuration: ignore / warn / hide per label value per labeler +- [ ] Store label preferences as `contentLabelPref` entries via `putPreferences` +- [ ] Adult content toggle (requires `adultContentEnabled` preference) +- [ ] Self-label support — render self-labels embedded in posts and profiles +- [ ] Labeler detail screen: show labeler creator, policies, and custom label definitions with localised names +- [ ] Drift table: `labeler_cache` (labeler_did, policies_json, fetched_at) for offline label definition lookup diff --git a/www/client-metadata.json b/www/client-metadata.json new file mode 100644 index 0000000..8b8d257 --- /dev/null +++ b/www/client-metadata.json @@ -0,0 +1,14 @@ +{ + "client_id": "https://lazurite.stormlightlabs.org/client-metadata.json", + "client_name": "Lazurite", + "client_uri": "https://lazurite.stormlightlabs.org", + "redirect_uris": ["http://127.0.0.1/callback"], + "scope": "atproto transition:generic", + "grant_types": ["authorization_code", "refresh_token"], + "response_types": ["code"], + "token_endpoint_auth_method": "none", + "application_type": "native", + "dpop_bound_access_tokens": true, + "software_id": "org.stormlightlabs.lazurite", + "software_version": "1.0.0" +} diff --git a/www/index.html b/www/index.html new file mode 100644 index 0000000..56415f9 --- /dev/null +++ b/www/index.html @@ -0,0 +1,384 @@ + + + + + + + + Lazurite - Coming Soon + + + + + + + + + +
+
+ +

Coming Soon

+
+ +
+
+
+ + In Active Development +
+ +

A better BlueSky client

+

+ Lazurite is a cross-platform mobile and desktop client for Bluesky, with a mobile-first design for Android + and iOS, with desktop support for a seamless experience. +

+
+ +
+
+
+ Palette icon +
+

Material You Design

+

Beautiful IBM Oxocarbon-inspired theme with dynamic color adaptation and smooth animations.

+
+ +
+
+ Message icon +
+

Full-Featured

+

Timeline, threads, profiles, notifications, search, direct messages, and smart folders.

+
+ +
+
+ Offline icon +
+

Offline-First

+

Smart caching with Drift database for lightning-fast performance and offline compose.

+
+ +
+
+ Folder icon +
+

Smart Folders

+

Create custom local-only feeds with powerful filtering and organization tools.

+
+
+ +
+

Built with...

+
+ Flutter + Dart + Material 3 + AT Protocol +
+
+ +
+ Follow development updates on Bluesky: + @desertthunder.dev +
+
+
+ + + + diff --git a/www/static/favicon.svg b/www/static/favicon.svg new file mode 100644 index 0000000..8910a41 --- /dev/null +++ b/www/static/favicon.svg @@ -0,0 +1,3 @@ + + + diff --git a/www/static/folder.svg b/www/static/folder.svg new file mode 100644 index 0000000..3381b61 --- /dev/null +++ b/www/static/folder.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/www/static/lock.svg b/www/static/lock.svg new file mode 100644 index 0000000..cfabe4b --- /dev/null +++ b/www/static/lock.svg @@ -0,0 +1,4 @@ + + + + diff --git a/www/static/message.svg b/www/static/message.svg new file mode 100644 index 0000000..2a58da8 --- /dev/null +++ b/www/static/message.svg @@ -0,0 +1,3 @@ + + + diff --git a/www/static/offline.svg b/www/static/offline.svg new file mode 100644 index 0000000..ec5182f --- /dev/null +++ b/www/static/offline.svg @@ -0,0 +1,3 @@ + + + diff --git a/www/static/palette.svg b/www/static/palette.svg new file mode 100644 index 0000000..800b308 --- /dev/null +++ b/www/static/palette.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/www/static/smartphone.svg b/www/static/smartphone.svg new file mode 100644 index 0000000..82c0321 --- /dev/null +++ b/www/static/smartphone.svg @@ -0,0 +1,6 @@ + + + + + +