···119119Build a `NotificationBloc` with events: `NotificationsRequested`,
120120`NotificationsRefreshed`, `NotificationsPageLoaded`, `NotificationsMarkedRead`.
121121122122-## Direct Messages
122122+## Post & Profile Actions
123123124124-DMs use the `chat.bsky.*` lexicon namespace. The DM feature has two views: a
125125-conversation list and a message thread.
124124+All post and profile interactions use the AT Protocol record model. Actions
125125+that create a relationship (like, repost, follow, block) write a record via
126126+`com.atproto.repo.createRecord` and undo by deleting via
127127+`com.atproto.repo.deleteRecord`. Muting is a server-side procedure call with
128128+no persistent record.
126129127130### API
128131129129-| Endpoint | Purpose |
130130-| -------------------------------------- | ----------------------------- |
131131-| `chat.bsky.convo.listConvos` | Paginated conversation list |
132132-| `chat.bsky.convo.getConvo` | Single conversation metadata |
133133-| `chat.bsky.convo.getMessages` | Paginated messages in a convo |
134134-| `chat.bsky.convo.sendMessage` | Send a message |
135135-| `chat.bsky.convo.deleteMessageForSelf` | Delete a message locally |
136136-| `chat.bsky.convo.muteConvo` | Mute a conversation |
137137-| `chat.bsky.convo.unmuteConvo` | Unmute a conversation |
138138-| `chat.bsky.convo.updateRead` | Mark conversation as read |
139139-| `chat.bsky.convo.getLog` | Polling for new events |
132132+| Endpoint | Purpose |
133133+| ------------------------------------- | --------------------------------------- |
134134+| `com.atproto.repo.createRecord` | Create like/repost/follow/block records |
135135+| `com.atproto.repo.deleteRecord` | Delete like/repost/follow/block records |
136136+| `app.bsky.graph.muteActor` | Mute an account |
137137+| `app.bsky.graph.unmuteActor` | Unmute an account |
138138+| `com.atproto.moderation.createReport` | Report a post or account |
140139141141-### Conversation List
140140+### Like
142141143143-`listConvos` returns conversations sorted by last message time. Each convo
144144-includes `id`, `members` (array of `profileViewBasic`), `lastMessage`,
145145-`unreadCount`, `muted`.
142142+Collection: `app.bsky.feed.like`. Record contains a `subject` (RepoStrongRef
143143+with the post's AT-URI and CID) and `createdAt`.
146144147147-Filter conversations into two tabs: **Primary** (accepted) and **Requests**
148148-(conversations the user has not yet responded to). A conversation is a
149149-"request" if the user has never sent a message in it.
145145+To unlike, extract the record key (rkey) from the `viewer.like` AT-URI and
146146+call `deleteRecord` with collection `app.bsky.feed.like` and that rkey.
150147151151-### Message Thread
148148+The `PostView.viewer.like` field is non-null when the current user has liked
149149+the post. Use this to drive the filled/outlined heart icon state.
152150153153-`getMessages` returns paginated `messageView` objects. Each message has `id`,
154154-`text`, `sender` (DID), `sentAt`. Messages are displayed in a standard chat
155155-bubble layout — the current user's messages right-aligned, others left-aligned.
151151+### Repost
156152157157-Support long-press to copy individual messages. Provide a "Copy All" option in
158158-the conversation overflow menu to copy the full thread.
153153+Collection: `app.bsky.feed.repost`. Record structure is identical to like —
154154+`subject` (RepoStrongRef) + `createdAt`.
159155160160-### Sending Messages
156156+To un-repost, extract the rkey from `viewer.repost` and delete the record.
161157162162-`sendMessage` takes `convoId` and `message` (object with `text`). To start a
163163-new conversation, the app calls `chat.bsky.convo.getConvoForMembers` with the
164164-target DID(s) — this returns an existing convo or creates a new one.
158158+The `PostView.viewer.repost` field is non-null when the current user has
159159+reposted. Use this for the repost icon state.
160160+161161+### Follow
162162+163163+Collection: `app.bsky.graph.follow`. Record contains `subject` (the target
164164+user's DID as a string) and `createdAt`.
165165+166166+To unfollow, extract the rkey from `viewer.following` and delete the record.
167167+168168+Viewer state fields on profiles:
169169+170170+- `viewer.following` — non-null AT-URI if the current user follows this profile
171171+- `viewer.followedBy` — non-null AT-URI if this profile follows the current user
172172+173173+### Mute
174174+175175+Mute and unmute are procedure calls (not record creation):
176176+177177+- `app.bsky.graph.muteActor` — input: `{ actor: DID }`
178178+- `app.bsky.graph.unmuteActor` — input: `{ actor: DID }`
179179+180180+Both return empty responses. The `viewer.muted` boolean on profiles reflects
181181+the current mute state. Muted accounts' posts are still fetched but should be
182182+visually de-emphasised or filtered in the UI based on user preference.
183183+184184+### Block
165185166166-| Endpoint | Purpose |
167167-| ------------------------------------ | --------------------- |
168168-| `chat.bsky.convo.getConvoForMembers` | Get or create a convo |
186186+Collection: `app.bsky.graph.block`. Record contains `subject` (the target
187187+user's DID) and `createdAt`.
169188170170-Build a `ConvoListBloc` with events: `ConvosRequested`, `ConvosRefreshed`,
171171-`ConvoMuted`, `ConvoUnmuted`.
189189+To unblock, extract the rkey from `viewer.blocking` and delete the record.
172190173173-Build a `MessageBloc` with events: `MessagesRequested`, `MessagesPageLoaded`,
174174-`MessageSent`, `MessageDeleted`, `ConvoMarkedRead`.
191191+Viewer state fields on profiles:
175192176176-## Account Switching
193193+- `viewer.blocking` — non-null AT-URI if the current user blocks this profile
194194+- `viewer.blockedBy` — boolean, true if this profile blocks the current user
195195+196196+When a user is blocked, their posts should be hidden from feeds and threads.
197197+Display a "You have blocked this user" placeholder in their profile view.
198198+199199+### Report
200200+201201+Reports use `com.atproto.moderation.createReport` with two subject types:
177202178178-Support multiple authenticated accounts with full data isolation. The
179179-`accounts` table (from Phase 1) already supports multiple rows keyed by DID.
203203+| Subject Type | Usage | Fields |
204204+| --------------- | ---------------------- | ------------- |
205205+| `RepoStrongRef` | Report a specific post | `uri` + `cid` |
206206+| `RepoRef` | Report an account | `did` |
180207181181-### Active Account
208208+Report reasons (from `com.atproto.moderation.defs`):
182209183183-Store the active account DID in the Drift `settings` table under key
184184-`active_account_did`. On launch, read this value and restore the session for
185185-that account.
210210+| Reason | Description |
211211+| ------------------ | --------------------------------- |
212212+| `reasonSpam` | Spam or unsolicited content |
213213+| `reasonViolation` | Violates community guidelines |
214214+| `reasonMisleading` | Misleading or deceptive content |
215215+| `reasonSexual` | Unwanted sexual content |
216216+| `reasonRude` | Harassment or rude behaviour |
217217+| `reasonOther` | Other (requires text explanation) |
186218187187-### Data Isolation
219219+The report dialog should present the reason picker and an optional free-text
220220+description field. Submitting returns a report ID for confirmation.
188221189189-All user-scoped tables must include an `account_did` FK column. Queries always
190190-filter by the active account's DID. Tables requiring this constraint:
222222+### Optimistic Updates
191223192192-- `drafts`
193193-- `saved_posts`
194194-- `search_history` (Phase 2)
195195-- `cached_posts` (add `account_did` if not present)
224224+All toggle actions (like, repost, follow, mute, block) should use optimistic
225225+UI updates:
196226197197-### Switching Flow
227227+1. Immediately update the local state (icon, count, button label).
228228+2. Fire the API call in the background.
229229+3. On success, reconcile with the server response (update the viewer URI).
230230+4. On failure, roll back the local state and show a snackbar error.
198231199199-1. User opens account switcher (bottom sheet or settings).
200200-2. Selects a different account.
201201-3. App updates `active_account_did` in settings.
202202-4. All Blocs receive a `AccountSwitched` event and reload their state for the
203203- new account.
204204-5. If the selected account's tokens are expired, attempt silent refresh. If
205205- refresh fails, navigate to login.
232232+Build a `PostActionCubit` that manages per-post action state (like, repost,
233233+save). It accepts the initial `ViewerState` from the post and exposes
234234+toggleable methods.
206235207207-### Adding Accounts
236236+Build a `ProfileActionCubit` that manages per-profile action state (follow,
237237+mute, block). It accepts the initial `ViewerState` from the profile and
238238+exposes toggleable methods.
208239209209-"Add Account" triggers the same OAuth flow from Phase 1. On success, a new row
210210-is inserted into `accounts`. The new account becomes the active account.
240240+### Post Action Bar
211241212212-Build an `AccountSwitcherCubit` that exposes the list of accounts and the
213213-active DID.
242242+The post action bar appears below every post and contains four buttons:
214243215215-## Offline Reading
244244+| Button | Icon | Tap action | Long-press |
245245+| ------ | ------ | ------------- | ----------------- |
246246+| Reply | chat | Open compose | — |
247247+| Repost | repeat | Toggle repost | Quote post option |
248248+| Like | heart | Toggle like | — |
249249+| Share | share | Share sheet | — |
216250217217-The app should render cached data when the network is unavailable. This builds
218218-on the `cached_posts` and `cached_profiles` tables from Phase 1.
251251+The bookmark (save) icon is placed in the post overflow menu alongside
252252+"Report" and "Copy link".
219253220220-### Cache Strategy
254254+Like and repost counts are displayed next to their respective icons. Counts
255255+update optimistically. The repost long-press opens a bottom sheet with
256256+"Repost" and "Quote Post" options.
221257222222-Cache the last-fetched page of each feed (timeline, pinned generators) in Drift
223223-as serialised JSON. On launch or feed switch, display cached data immediately,
224224-then fetch fresh data in the background. If the fetch fails, keep showing the
225225-cache with a "You're offline" banner.
258258+### Profile Action Buttons
226259227227-### Offline Indicators
260260+The profile header shows a primary action button based on the relationship:
228261229229-- A persistent banner at the top of the screen when connectivity is lost.
230230-- Disable actions that require network (compose, like, repost, follow) and show
231231- a tooltip explaining why.
232232-- Notifications and DM screens show an empty state with "No connection" when
233233- offline and no cached data exists.
262262+| State | Button label | Tap action |
263263+| ---------------- | ------------ | -------------- |
264264+| Not following | "Follow" | Create follow |
265265+| Following | "Following" | Unfollow sheet |
266266+| Blocked by them | — | No button |
267267+| You blocked them | "Unblock" | Delete block |
234268235235-### Network Detection
269269+The profile overflow menu (three-dot icon) contains: Mute / Unmute, Block /
270270+Unblock, Report, Copy DID, Share profile.
236271237237-Use the **connectivity_plus** package to monitor network state changes. Expose
238238-connectivity as a stream via a `ConnectivityCubit` that all screens observe.
272272+Mute and block actions should show a confirmation dialog before proceeding.
239273240274## Saved Posts
241275···264298Build a `SavedPostsCubit` that reads/writes the `saved_posts` table and
265299exposes a stream of saved post URIs for quick lookup (to show filled vs
266300outlined bookmark icons in the feed).
267267-268268-## Jump to Profile
269269-270270-Add a floating action button on the search screen. Tapping it opens a dialog
271271-with a text field for entering a handle. Use
272272-`app.bsky.actor.searchActorsTypeahead` to provide autocomplete suggestions as
273273-the user types. Selecting a result or pressing enter navigates to that user's
274274-profile screen.
275275-276276-| Endpoint | Purpose |
277277-| -------------------------------------- | ------------------- |
278278-| `app.bsky.actor.searchActorsTypeahead` | Handle autocomplete |
279279-| `app.bsky.actor.getProfile` | Full profile fetch |
+329
docs/specs/phase-4.md
···11+# Phase 4
22+33+## Direct Messages
44+55+DMs use the `chat.bsky.*` lexicon namespace. The DM feature has two views: a
66+conversation list and a message thread.
77+88+### API
99+1010+| Endpoint | Purpose |
1111+| -------------------------------------- | ----------------------------- |
1212+| `chat.bsky.convo.listConvos` | Paginated conversation list |
1313+| `chat.bsky.convo.getConvo` | Single conversation metadata |
1414+| `chat.bsky.convo.getMessages` | Paginated messages in a convo |
1515+| `chat.bsky.convo.sendMessage` | Send a message |
1616+| `chat.bsky.convo.deleteMessageForSelf` | Delete a message locally |
1717+| `chat.bsky.convo.muteConvo` | Mute a conversation |
1818+| `chat.bsky.convo.unmuteConvo` | Unmute a conversation |
1919+| `chat.bsky.convo.updateRead` | Mark conversation as read |
2020+| `chat.bsky.convo.getLog` | Polling for new events |
2121+2222+### Conversation List
2323+2424+`listConvos` returns conversations sorted by last message time. Each convo
2525+includes `id`, `members` (array of `profileViewBasic`), `lastMessage`,
2626+`unreadCount`, `muted`.
2727+2828+Filter conversations into two tabs: **Primary** (accepted) and **Requests**
2929+(conversations the user has not yet responded to). A conversation is a
3030+"request" if the user has never sent a message in it.
3131+3232+### Message Thread
3333+3434+`getMessages` returns paginated `messageView` objects. Each message has `id`,
3535+`text`, `sender` (DID), `sentAt`. Messages are displayed in a standard chat
3636+bubble layout — the current user's messages right-aligned, others left-aligned.
3737+3838+Support long-press to copy individual messages. Provide a "Copy All" option in
3939+the conversation overflow menu to copy the full thread.
4040+4141+### Sending Messages
4242+4343+`sendMessage` takes `convoId` and `message` (object with `text`). To start a
4444+new conversation, the app calls `chat.bsky.convo.getConvoForMembers` with the
4545+target DID(s) — this returns an existing convo or creates a new one.
4646+4747+| Endpoint | Purpose |
4848+| ------------------------------------ | --------------------- |
4949+| `chat.bsky.convo.getConvoForMembers` | Get or create a convo |
5050+5151+Build a `ConvoListBloc` with events: `ConvosRequested`, `ConvosRefreshed`,
5252+`ConvoMuted`, `ConvoUnmuted`.
5353+5454+Build a `MessageBloc` with events: `MessagesRequested`, `MessagesPageLoaded`,
5555+`MessageSent`, `MessageDeleted`, `ConvoMarkedRead`.
5656+5757+## Account Switching
5858+5959+Support multiple authenticated accounts with full data isolation. The
6060+`accounts` table (from Phase 1) already supports multiple rows keyed by DID.
6161+6262+### Active Account
6363+6464+Store the active account DID in the Drift `settings` table under key
6565+`active_account_did`. On launch, read this value and restore the session for
6666+that account.
6767+6868+### Data Isolation
6969+7070+All user-scoped tables must include an `account_did` FK column. Queries always
7171+filter by the active account's DID. Tables requiring this constraint:
7272+7373+- `drafts`
7474+- `saved_posts`
7575+- `search_history` (Phase 2)
7676+- `cached_posts` (add `account_did` if not present)
7777+7878+### Switching Flow
7979+8080+1. User opens account switcher (bottom sheet or settings).
8181+2. Selects a different account.
8282+3. App updates `active_account_did` in settings.
8383+4. All Blocs receive a `AccountSwitched` event and reload their state for the
8484+ new account.
8585+5. If the selected account's tokens are expired, attempt silent refresh. If
8686+ refresh fails, navigate to login.
8787+8888+### Adding Accounts
8989+9090+"Add Account" triggers the same OAuth flow from Phase 1. On success, a new row
9191+is inserted into `accounts`. The new account becomes the active account.
9292+9393+Build an `AccountSwitcherCubit` that exposes the list of accounts and the
9494+active DID.
9595+9696+## Offline Reading
9797+9898+The app should render cached data when the network is unavailable. This builds
9999+on the `cached_posts` and `cached_profiles` tables from Phase 1.
100100+101101+### Cache Strategy
102102+103103+Cache the last-fetched page of each feed (timeline, pinned generators) in Drift
104104+as serialised JSON. On launch or feed switch, display cached data immediately,
105105+then fetch fresh data in the background. If the fetch fails, keep showing the
106106+cache with a "You're offline" banner.
107107+108108+### Offline Indicators
109109+110110+- A persistent banner at the top of the screen when connectivity is lost.
111111+- Disable actions that require network (compose, like, repost, follow) and show
112112+ a tooltip explaining why.
113113+- Notifications and DM screens show an empty state with "No connection" when
114114+ offline and no cached data exists.
115115+116116+### Network Detection
117117+118118+Use the **connectivity_plus** package to monitor network state changes. Expose
119119+connectivity as a stream via a `ConnectivityCubit` that all screens observe.
120120+121121+## Jump to Profile
122122+123123+Add a floating action button on the search screen. Tapping it opens a dialog
124124+with a text field for entering a handle. Use
125125+`app.bsky.actor.searchActorsTypeahead` to provide autocomplete suggestions as
126126+the user types. Selecting a result or pressing enter navigates to that user's
127127+profile screen.
128128+129129+| Endpoint | Purpose |
130130+| -------------------------------------- | ------------------- |
131131+| `app.bsky.actor.searchActorsTypeahead` | Handle autocomplete |
132132+| `app.bsky.actor.getProfile` | Full profile fetch |
133133+134134+## Labelers & Content Moderation
135135+136136+Labelers are independent services that produce metadata labels about content
137137+and accounts. Users subscribe to labelers and configure how each label type
138138+affects their experience. The `bluesky` Dart package includes a built-in
139139+moderation decision engine that handles label interpretation.
140140+141141+### Architecture Overview
142142+143143+```text
144144+User Preferences ──► ModerationOpts ──► moderatePost() ──► ModerationDecision
145145+Label Definitions ─┘ moderateProfile() └─► getUI(context)
146146+ moderateNotification() └─► ModerationUI
147147+ ├─ filters
148148+ ├─ blurs
149149+ ├─ alerts
150150+ └─ informs
151151+```
152152+153153+### API
154154+155155+| Endpoint | Purpose |
156156+| ------------------------------- | ----------------------------------- |
157157+| `app.bsky.labeler.getServices` | Fetch labeler details by DID |
158158+| `app.bsky.actor.getPreferences` | Read labeler subscriptions + prefs |
159159+| `app.bsky.actor.putPreferences` | Write labeler subscriptions + prefs |
160160+161161+### Labeler Subscriptions
162162+163163+Users subscribe to up to 20 labelers. Subscriptions are stored as a
164164+`labelersPref` entry in the user's preferences. Each entry contains a labeler
165165+DID. The official Bluesky moderation labeler is always active and does not
166166+count against the 20-labeler limit.
167167+168168+On every XRPC request that returns content (feed, thread, profile, search,
169169+notifications), include the `atproto-accept-labelers` HTTP header with a
170170+comma-separated list of subscribed labeler DIDs. The AppView fetches labels
171171+from those labelers and attaches them to the response.
172172+173173+### Label Data Model
174174+175175+A label is a lightweight annotation attached to content or an account:
176176+177177+| Field | Type | Description |
178178+| ----- | --------- | --------------------------------------------------- |
179179+| `src` | string | DID of the labeler that created the label |
180180+| `uri` | string | AT-URI of the target resource (or DID for accounts) |
181181+| `cid` | string? | Optional CID for a specific version of the target |
182182+| `val` | string | Label value identifier (e.g. "porn", "spam") |
183183+| `neg` | bool? | If true, negates (retracts) a previous label |
184184+| `cts` | datetime | Creation timestamp |
185185+| `exp` | datetime? | Expiration timestamp |
186186+187187+### Label Behaviour Definitions
188188+189189+Each label value is defined by three axes that determine its UI effect:
190190+191191+| Axis | Values | Description |
192192+| ---------------- | -------------------------- | -------------------------------- |
193193+| `blurs` | `content`, `media`, `none` | What gets blurred |
194194+| `severity` | `inform`, `alert`, `none` | Badge type (neutral vs warning) |
195195+| `defaultSetting` | `ignore`, `warn`, `hide` | Default user-configurable action |
196196+197197+Global (protocol-defined) label values include:
198198+199199+| Label | Blurs | Default | Notes |
200200+| ---------------- | ------- | ------- | ------------------------------- |
201201+| `!hide` | content | hide | Non-configurable, no override |
202202+| `!warn` | content | warn | Non-configurable, click-through |
203203+| `porn` | media | hide | Adult, 18+ required |
204204+| `sexual` | media | warn | Adult, 18+ required |
205205+| `graphic-media` | media | warn | Adult, 18+ required |
206206+| `nudity` | media | ignore | Not 18+ restricted |
207207+| `dmca-violation` | content | hide | Non-configurable |
208208+| `doxxing` | content | hide | Non-configurable |
209209+210210+Labelers may also define custom label values with localised names and
211211+descriptions via `LabelValueDefinition`.
212212+213213+### Self-Labels
214214+215215+Authors can embed `selfLabels` directly in their posts and profiles. Only
216216+global label values are valid as self-labels. Self-labels are treated as if
217217+the author is the label source and follow the same behaviour rules.
218218+219219+### Moderation Decision Pipeline
220220+221221+Use the `bluesky` package's moderation engine for all content display:
222222+223223+1. **Build `ModerationOpts`** from user preferences:
224224+ - `adultContentEnabled` — boolean from preferences
225225+ - `labels` — map of label value → preference (`ignore` / `warn` / `hide`)
226226+ - `labelers` — list of subscribed labeler DIDs
227227+ - `labelDefs` — map of labeler DID → list of custom `InterpretedLabelValueDefinition`
228228+229229+2. **Run moderation** on every piece of content before display:
230230+ - `moderatePost(subject, opts)` for posts
231231+ - `moderateProfile(subject, opts)` for profiles
232232+ - `moderateNotification(subject, opts)` for notifications
233233+234234+3. **Apply `ModerationUI`** via `decision.getUI(context)` for each display
235235+ context:
236236+ - `contentList` — post in a feed or search results
237237+ - `contentView` — post in a thread view
238238+ - `contentMedia` — images/media within a post
239239+ - `avatar` — profile avatar
240240+ - `profileList` — profile in a list
241241+ - `profileView` — full profile screen
242242+243243+The `ModerationUI` object contains:
244244+245245+- `filters` — content should be removed from the list entirely
246246+- `blurs` — content should be placed behind a click-through overlay
247247+- `alerts` — show a warning badge (negative connotation)
248248+- `informs` — show an informational badge (neutral)
249249+- `noOverride` — blur cannot be dismissed by the user
250250+251251+### Content Label Preferences
252252+253253+Users configure per-label visibility via `contentLabelPref` entries in
254254+preferences. Each entry specifies:
255255+256256+| Field | Description |
257257+| ------------ | ------------------------------------------- |
258258+| `labelerDid` | Scope to a specific labeler (null = global) |
259259+| `label` | The label value string |
260260+| `visibility` | `ignore`, `warn`, `hide`, or `show` |
261261+262262+Labels with `adultOnly: true` in their definition require the user to have
263263+`adultContentEnabled` set to true. If adult content is disabled, these labels
264264+always apply as `hide` regardless of user preference.
265265+266266+### Rendering Rules
267267+268268+**Blur overlay**: When `blurs` is non-empty, render a semi-transparent overlay
269269+with the label name and a "Show" button. If `noOverride` is true, omit the
270270+"Show" button — the content cannot be revealed.
271271+272272+**Alert badge**: When `alerts` is non-empty, show a warning icon with the
273273+label name below the content or next to the profile name.
274274+275275+**Inform badge**: When `informs` is non-empty, show an info icon with the
276276+label name. Use a neutral colour (not red/warning).
277277+278278+**Filtering**: When `filters` is non-empty, remove the content from the
279279+current list view entirely. Do not render a placeholder.
280280+281281+**Media blur**: When the `contentMedia` context has blurs, blur only images
282282+and embedded media while leaving the post text visible.
283283+284284+**Avatar blur**: When the `avatar` context has blurs, show a generic
285285+placeholder avatar.
286286+287287+### ModerationService
288288+289289+Build a `ModerationService` that:
290290+291291+1. Loads labeler subscriptions and label preferences from user preferences on
292292+ login and account switch.
293293+2. Fetches labeler details (`getServices` with `detailed: true`) for all
294294+ subscribed labelers to obtain custom label definitions.
295295+3. Caches label definitions in a Drift `labeler_cache` table for offline use.
296296+4. Constructs `ModerationOpts` and exposes it as a stream that updates when
297297+ preferences change.
298298+5. Provides convenience methods: `moderatePost()`, `moderateProfile()`,
299299+ `moderateNotification()`.
300300+301301+### Labeler Cache Table
302302+303303+| Column | Type | Notes |
304304+| --------------- | -------- | -------------------------------- |
305305+| `labeler_did` | text | PK, DID of the labeler |
306306+| `policies_json` | text | Serialised `LabelerViewDetailed` |
307307+| `fetched_at` | datetime | When the data was last refreshed |
308308+309309+Refresh the cache when the user opens the labeler management screen or when a
310310+subscribed labeler's data is older than 24 hours.
311311+312312+### Labeler Management UI
313313+314314+The labeler management screen (accessible from Settings) shows:
315315+316316+- A list of subscribed labelers, each showing: creator avatar, display name,
317317+ description, and number of label definitions.
318318+- Tapping a labeler opens a detail screen showing all label values the labeler
319319+ publishes, with the user's current preference (ignore/warn/hide) for each.
320320+- A toggle for subscribing/unsubscribing to the labeler.
321321+- An "Adult content" toggle at the top of the settings screen that gates all
322322+ 18+ label preferences.
323323+324324+### Header Integration
325325+326326+The XRPC client must be modified to include the `atproto-accept-labelers`
327327+header on all outgoing requests. The header value is a comma-separated list of
328328+labeler DIDs from the user's `labelersPref`. This should be set once on login
329329+and updated whenever preferences change.
+17-40
docs/tasks/phase-3.md
···2525- [ ] Mark as read via `updateSeen` when notifications screen opens
2626- [ ] Tap notification to navigate to relevant post or profile
27272828-## M10 — Direct Messages
2828+## M10 — Post & Profile Actions
29293030-- [ ] Conversation list screen via `chat.bsky.convo.listConvos` with pagination
3131-- [ ] `ConvoListBloc` — events: `ConvosRequested`, `ConvosRefreshed`, `ConvoMuted`, `ConvoUnmuted`
3232-- [ ] Primary / Requests tab filtering on conversation list
3333-- [ ] Message thread screen via `chat.bsky.convo.getMessages` with pagination
3434-- [ ] `MessageBloc` — events: `MessagesRequested`, `MessagesPageLoaded`, `MessageSent`, `MessageDeleted`, `ConvoMarkedRead`
3535-- [ ] Chat bubble layout — current user right-aligned, others left-aligned
3636-- [ ] Send messages via `chat.bsky.convo.sendMessage`
3737-- [ ] New conversation via `chat.bsky.convo.getConvoForMembers`
3838-- [ ] Long-press to copy individual messages, overflow menu "Copy All" for full thread
3939-- [ ] Mute / unmute conversations
4040-- [ ] Mark conversation as read via `chat.bsky.convo.updateRead`
3030+- [ ] `PostActionRepository` — like, repost, delete via `com.atproto.repo.createRecord` / `deleteRecord`
3131+- [ ] `PostActionCubit` — optimistic state updates for like / repost toggle with rollback on failure
3232+- [ ] Like toggle: create `app.bsky.feed.like` record or delete by rkey; update `viewer.like` and `likeCount`
3333+- [ ] Repost toggle: create `app.bsky.feed.repost` record or delete by rkey; update `viewer.repost` and `repostCount`
3434+- [ ] Post action bar UI — like, repost, reply, share buttons with animated state transitions
3535+- [ ] `ProfileActionRepository` — follow, mute, block, report
3636+- [ ] `ProfileActionCubit` — optimistic follow/mute/block state with rollback
3737+- [ ] Follow toggle: create `app.bsky.graph.follow` record or delete by rkey; update `viewer.following`
3838+- [ ] Mute toggle via `app.bsky.graph.muteActor` / `unmuteActor`; update `viewer.muted`
3939+- [ ] Block toggle: create `app.bsky.graph.block` record or delete by rkey; update `viewer.blocking`
4040+- [ ] Profile action buttons: Follow / Following / Mute / Block in profile header and overflow menu
4141+- [ ] Report dialog: reason picker + optional description, submit via `com.atproto.moderation.createReport`
4242+- [ ] Report for both posts (RepoStrongRef subject) and accounts (RepoRef subject)
4343+- [ ] Confirmation dialog before mute / block actions
4444+- [ ] Thread muting via `app.bsky.feed.threadgate` awareness (show muted-thread indicator)
41454242-## M11 — Account Switching
4343-4444-- [ ] `AccountSwitcherCubit` exposing account list and active DID
4545-- [ ] Account switcher bottom sheet UI — list accounts with avatars and handles
4646-- [ ] Store `active_account_did` in Drift `settings` table
4747-- [ ] Drift migration: add `account_did` column to `cached_posts` if not present
4848-- [ ] All user-scoped queries filter by active account DID
4949-- [ ] Broadcast `AccountSwitched` event to all Blocs on switch
5050-- [ ] "Add Account" button triggers OAuth flow, inserts new `accounts` row
5151-- [ ] Silent token refresh on account switch; navigate to login on failure
5252-5353-## M12 — Offline Reading & Network Resilience
5454-5555-- [ ] `ConnectivityCubit` via **connectivity_plus** — expose network state stream
5656-- [ ] Cache last-fetched feed page as serialised JSON in Drift
5757-- [ ] Display cached data immediately on launch, refresh in background
5858-- [ ] "You're offline" banner when connectivity is lost
5959-- [ ] Disable network-dependent actions (compose, like, repost, follow) when offline with tooltip
6060-- [ ] Notifications and DM screens show "No connection" empty state when offline with no cache
6161-6262-## M13 — Saved Posts
4646+## M11 — Saved Posts
63476448- [ ] Drift migration: add `saved_posts` table (id, account_did, post_uri, post_json, saved_at) with unique constraint on (account_did, post_uri)
6549- [ ] `SavedPostsCubit` — read/write saved posts, expose stream of saved URIs for icon state
6650- [ ] Bookmark icon on post action bar — toggle saved state
6751- [ ] Saved posts list screen accessible from profile or settings
6868-6969-## M14 — Jump to Profile
7070-7171-- [ ] Floating action button on search screen
7272-- [ ] Handle input dialog with autocomplete via `searchActorsTypeahead`
7373-- [ ] Navigate to profile screen on selection or enter
7474-- [ ] Update bottom navigation to include Notifications and Messages tabs (5-tab layout)
+61
docs/tasks/phase-4.md
···11+# Phase 4 Milestones
22+33+## M12 — Direct Messages
44+55+- [ ] Conversation list screen via `chat.bsky.convo.listConvos` with pagination
66+- [ ] `ConvoListBloc` — events: `ConvosRequested`, `ConvosRefreshed`, `ConvoMuted`, `ConvoUnmuted`
77+- [ ] Primary / Requests tab filtering on conversation list
88+- [ ] Message thread screen via `chat.bsky.convo.getMessages` with pagination
99+- [ ] `MessageBloc` — events: `MessagesRequested`, `MessagesPageLoaded`, `MessageSent`, `MessageDeleted`, `ConvoMarkedRead`
1010+- [ ] Chat bubble layout — current user right-aligned, others left-aligned
1111+- [ ] Send messages via `chat.bsky.convo.sendMessage`
1212+- [ ] New conversation via `chat.bsky.convo.getConvoForMembers`
1313+- [ ] Long-press to copy individual messages, overflow menu "Copy All" for full thread
1414+- [ ] Mute / unmute conversations
1515+- [ ] Mark conversation as read via `chat.bsky.convo.updateRead`
1616+1717+## M13 — Account Switching
1818+1919+- [ ] `AccountSwitcherCubit` exposing account list and active DID
2020+- [ ] Account switcher bottom sheet UI — list accounts with avatars and handles
2121+- [ ] Store `active_account_did` in Drift `settings` table
2222+- [ ] Drift migration: add `account_did` column to `cached_posts` if not present
2323+- [ ] All user-scoped queries filter by active account DID
2424+- [ ] Broadcast `AccountSwitched` event to all Blocs on switch
2525+- [ ] "Add Account" button triggers OAuth flow, inserts new `accounts` row
2626+- [ ] Silent token refresh on account switch; navigate to login on failure
2727+2828+## M14 — Offline Reading & Network Resilience
2929+3030+- [ ] `ConnectivityCubit` via **connectivity_plus** — expose network state stream
3131+- [ ] Cache last-fetched feed page as serialised JSON in Drift
3232+- [ ] Display cached data immediately on launch, refresh in background
3333+- [ ] "You're offline" banner when connectivity is lost
3434+- [ ] Disable network-dependent actions (compose, like, repost, follow) when offline with tooltip
3535+- [ ] Notifications and DM screens show "No connection" empty state when offline with no cache
3636+3737+## M15 — Jump to Profile
3838+3939+- [ ] Floating action button on search screen
4040+- [ ] Handle input dialog with autocomplete via `searchActorsTypeahead`
4141+- [ ] Navigate to profile screen on selection or enter
4242+- [ ] Update bottom navigation to include Notifications and Messages tabs (5-tab layout)
4343+4444+## M16 — Labelers & Content Moderation
4545+4646+- [ ] Fetch user's labeler subscriptions from preferences via `app.bsky.actor.getPreferences` (`labelersPref`)
4747+- [ ] Include subscribed labeler DIDs in `atproto-accept-labelers` header on all XRPC requests
4848+- [ ] `ModerationService` — wraps the `bluesky` package's `moderatePost`, `moderateProfile`, `moderateNotification` functions
4949+- [ ] Run moderation decisions on all displayed posts and profiles
5050+- [ ] Apply `ModerationUI` results: filter, blur, alert, inform per display context (contentList, contentView, contentMedia, avatar, profileList, profileView)
5151+- [ ] Blur overlay on posts/media with click-through "Show content" button
5252+- [ ] Warning badges on profiles and posts for alert/inform labels
5353+- [ ] Content filtering — remove posts with `filter` decisions from feed and notification lists
5454+- [ ] Labeler management screen: list subscribed labelers via `app.bsky.labeler.getServices`
5555+- [ ] Subscribe / unsubscribe to labelers by updating `labelersPref` via `putPreferences`
5656+- [ ] Per-label preference configuration: ignore / warn / hide per label value per labeler
5757+- [ ] Store label preferences as `contentLabelPref` entries via `putPreferences`
5858+- [ ] Adult content toggle (requires `adultContentEnabled` preference)
5959+- [ ] Self-label support — render self-labels embedded in posts and profiles
6060+- [ ] Labeler detail screen: show labeler creator, policies, and custom label definitions with localised names
6161+- [ ] Drift table: `labeler_cache` (labeler_did, policies_json, fetched_at) for offline label definition lookup