diff --git a/CONTEXT.md b/CONTEXT.md index a440fc61..5fdf7c69 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -3,7 +3,7 @@ ## Metadata - Domain: late.sh - Terminal Clubhouse for Developers - Primary audience: LLM agents working on this codebase, human contributors -- Last updated: 2026-05-13 (CLI details in `late-cli/CONTEXT.md`; Web details in `late-web/CONTEXT.md`; Arcade details in `late-ssh/src/app/arcade/CONTEXT.md`; Rooms details in `late-ssh/src/app/rooms/CONTEXT.md`; Chat details in `late-ssh/src/app/chat/CONTEXT.md`; Artboard details in `late-ssh/src/app/artboard/CONTEXT.md`) +- Last updated: 2026-05-14 (CLI details in `late-cli/CONTEXT.md`; Web details in `late-web/CONTEXT.md`; Arcade details in `late-ssh/src/app/arcade/CONTEXT.md`; Rooms details in `late-ssh/src/app/rooms/CONTEXT.md`; Chat details in `late-ssh/src/app/chat/CONTEXT.md`; Artboard details in `late-ssh/src/app/artboard/CONTEXT.md`) - Status: Active - Stability note: Sections marked `[STABLE]` should change rarely. Sections marked `[VOLATILE]` are expected to change often. @@ -245,7 +245,7 @@ flowchart LR - `ProfileService` (in `app/profile/svc.rs`) exposes per-user `watch` snapshots backed by service-owned maps (`subscribe_snapshot(user_id)`). - `LeaderboardService` exposes a shared `watch::Receiver>` refreshed from DB every 30s. Contains today's champions, daily completion statuses, extended all-time/monthly high scores (Tetris, 2048, Snake), monthly chip earners, monthly Arcade champion points, and chip leaders (top balances). Compact Hub leaderboard panels render top rows plus calculation hints and an "around you" slice when the current user is outside the visible top list; Arcade Wins uses daily puzzle weighting (easy/draw-1 = 1, medium = 3, hard/draw-3 = 5). Chat username glyphs come from bonsai state. - `Hub` (in `app/hub`) is the global modal opened by Ctrl+G. It owns cross-product surfaces such as Leaderboard, Dailies, Shop, and Events. It may summarize data from Arcade, Rooms, and economy services, but those domains keep their own runtime/service ownership. -- `ChipService` (in `app/arcade/chips/svc.rs`) manages the Late Chips economy: `ensure_chips(user_id)` grants the daily 500-chip stipend on login, `grant_daily_bonus_task(user_id, difficulty_key)` awards 50/100/150 chips on daily puzzle completion. Daily Arcade services hold a `ChipService` clone and call it in `record_win_task()`. +- `ChipService` (in `app/arcade/chips/svc.rs`) manages the Late Chips economy: `ensure_chips(user_id)` creates new chip rows with 1000 chips, `grant_daily_bonus_task(user_id, difficulty_key)` awards 50/100/150 chips on daily puzzle completion. Daily Arcade services hold a `ChipService` clone and call it in `record_win_task()`. - `Activity` (in `app/activity`) owns the structured global user-action event type, channel helpers, and `ActivityPublisher` username lookup helper. `ActivityEvent` carries `user_id`, `username`, display `action`, structured `ActivityKind`, category, and timestamp. Dashboard/sidebar display drains the same global broadcast stream through `ActivityFilter::dashboard()`. Future daily-challenge systems should subscribe to this channel and consume every event in order rather than adding per-feature challenge hooks. - `RoomsService` (in `app/rooms/svc.rs`) owns persistent game-room creation/listing/deletion over `game_rooms` + associated `chat_rooms`, publishes `RoomsSnapshot` via `watch`, and emits `RoomsEvent` success/failure banners. - `BlackjackTableManager` / `BlackjackService` own process-local per-room Blackjack runtime state. Detailed Rooms/Blackjack contracts live in `late-ssh/src/app/rooms/CONTEXT.md`. @@ -564,7 +564,7 @@ late-sh/ | BonsaiTree | `bonsai_trees` | `user_id` UNIQUE, growth_points, last_watered DATE, seed BIGINT, is_alive BOOLEAN | | BonsaiGrave | `bonsai_graveyard` | `user_id` FK (not unique — multiple deaths), survived_days, died_at | | BonsaiDailyCare | `bonsai_daily_care` | `UNIQUE(user_id, care_date)`, UTC daily care row with watered flag, generated branch goal, cut branch ids, and one-shot water/prune penalty flags | -| UserChips | `user_chips` | `user_id` PK/FK, `balance` BIGINT (floor=100), `last_stipend_date` DATE | +| UserChips | `user_chips` | `user_id` PK/FK, `balance` BIGINT (new users start at 1000; busted-player floor restore is 100), `last_stipend_date` DATE | | Showcase | `showcases` | `user_id` FK; `title` 1-120, `url` 1-2000, `description` 1-800, `tags` TEXT[] (lowercased, ≤8). Listed newest-first, edit/delete restricted to author or admin | | ShowcaseFeedRead | `showcase_feed_reads` | `user_id` PK/FK, `last_read_at` timestamp cursor for per-user Showcase unread counts | | WorkProfile | `work_profiles` | `user_id` UNIQUE FK; `slug` UNIQUE (`w_` + 12 lowercase alnum), `headline`, status (`open`, `casual`, `not-looking`), type/location, links, skills, summary. Listed latest-update-first, edit/delete restricted to author or admin | @@ -627,6 +627,7 @@ Known gaps/risks: - **Single-replica assumption:** Several structures are purely in-memory and not shared across processes (see multi-replica notes below) - **SSH pod drain window:** `infra/service-ssh.tf` sets `termination_grace_period_seconds = 21600` (6h) so rolling updates can stop new connections while allowing existing SSH sessions to drain for a long window before Kubernetes sends SIGKILL. - **SSH ingress reload risk:** `ssh late.sh` currently reaches `late-ssh` through RKE2 ingress-nginx TCP passthrough (`infra/ssh-tcp.tf`, port `22 -> service-ssh-sv:2222::PROXY`). Long-lived SSH sessions can be dropped after any ingress-nginx config reload because old workers are terminated after `worker_shutdown_timeout` (observed 2026-04-29 after cert-manager renewed `service-web-tls`: reload at `19:56:37Z`, mass SSH/WS disconnect at `20:00:38Z`, matching the 240s timeout). Future infra improvement: stop routing SSH through ingress-nginx; use a dedicated TCP LoadBalancer/NodePort/host proxy for SSH so HTTP/TLS reloads cannot kill SSH sessions. Short-term mitigation: increase ingress-nginx `worker-shutdown-timeout`, but that only delays the disconnect. +- **Postgres primary CPU saturation from discover-room fanout:** Observed 2026-05-14 in Kubernetes: CNPG primary `postgres-1` was healthy but pinned near its `1` CPU limit, while the node still had spare CPU. `pg_stat_activity` showed `service-ssh` (`10.42.0.47`) running 8 concurrent `app` sessions on the same public topic-room discover query. The old query joined `chat_rooms -> chat_room_members -> chat_messages` and then used `COUNT(DISTINCT ...)`, producing an estimated ~4.48M joined rows before aggregation. Preferred fix is query shape first: aggregate member/message counts separately (current `ChatRoom::list_discover_public_topic_rooms` uses `LATERAL` aggregates), then raise the CNPG CPU limit from `1` to `2` only for headroom. Secondary log noise during the same check: repeated `idx_users_username_lower` duplicate-key errors from profile updates; do not mistake those for the main CPU source unless active queries point there. - **IPv6 ingress status:** RKE2/CNI `hostPort` exposes the current ingress-nginx path for IPv4 only; do not switch the main ingress controller to `hostNetwork` without a rollout plan. Public IPv6 is handled by the separate `kube-system/ipv6-proxy` HAProxy DaemonSet in `infra/ipv6-proxy.tf`, binding `2a01:4f9:c013:2ae1::1` on `80`, `443`, and `22`; HTTP(S) forwards to localhost ingress hostPorts, while SSH forwards to `service-ssh-sv:2222` with PROXY protocol. Verified working externally on 2026-05-03; `Network is unreachable` during `ssh -6 late.sh` means the client lacks IPv6 egress. - **Stateful VT parsing in `late-ssh/src/app/input.rs`:** SSH input now runs through a persistent `vte::Parser`, so CSI/SS3 sequences and bracketed paste survive split russh reads instead of assuming the whole escape sequence lands in one chunk. That removes the old split-paste failure where `[200~` / `[201~` residue or embedded newlines could leak through as live keystrokes. The app still keeps two pragmatic layers on top: `is_likely_paste` heuristically treats large printable unmarked chunks as paste for terminals without bracketed paste, and `sanitize_paste_markers`/`strip_paste_markers` still scrub stored residue defensively when copying URLs from older polluted state. Standalone `Esc` is resolved on a short tick delay so split escape sequences are not mistaken for cancel keys. diff --git a/late-core/src/models/chat_room.rs b/late-core/src/models/chat_room.rs index 548596d5..6e6fdf94 100644 --- a/late-core/src/models/chat_room.rs +++ b/late-core/src/models/chat_room.rs @@ -241,18 +241,26 @@ impl ChatRoom { .query( "SELECT r.id, r.slug, - COUNT(DISTINCT m.user_id)::bigint AS member_count, - COUNT(DISTINCT msg.id)::bigint AS message_count, - MAX(msg.created) AS last_message_at + COALESCE(m.member_count, 0)::bigint AS member_count, + COALESCE(msg.message_count, 0)::bigint AS message_count, + msg.last_message_at FROM chat_rooms r - LEFT JOIN chat_room_members m ON m.room_id = r.id - LEFT JOIN chat_messages msg ON msg.room_id = r.id + LEFT JOIN LATERAL ( + SELECT COUNT(*)::bigint AS member_count + FROM chat_room_members m + WHERE m.room_id = r.id + ) m ON true + LEFT JOIN LATERAL ( + SELECT COUNT(*)::bigint AS message_count, + MAX(created) AS last_message_at + FROM chat_messages msg + WHERE msg.room_id = r.id + ) msg ON true WHERE r.kind = 'topic' AND r.visibility = 'public' AND r.permanent = false - GROUP BY r.id, r.slug ORDER BY - COALESCE(MAX(msg.created), r.created) DESC, + COALESCE(msg.last_message_at, r.created) DESC, message_count DESC, member_count DESC, r.slug ASC", diff --git a/late-core/src/models/chips.rs b/late-core/src/models/chips.rs index 5c5cb033..853ec843 100644 --- a/late-core/src/models/chips.rs +++ b/late-core/src/models/chips.rs @@ -7,6 +7,7 @@ use uuid::Uuid; pub const BONSAI_WATER_BONUS: i64 = 200; pub const CHIP_FLOOR: i64 = 100; +pub const INITIAL_CHIP_BALANCE: i64 = 1_000; /// Map a difficulty key to its chip bonus. pub fn difficulty_bonus(key: &str) -> i64 { @@ -44,7 +45,7 @@ impl UserChips { VALUES ($1, $2) ON CONFLICT (user_id) DO NOTHING RETURNING *", - &[&user_id, &CHIP_FLOOR], + &[&user_id, &INITIAL_CHIP_BALANCE], ) .await; match row { @@ -205,5 +206,6 @@ mod tests { fn constants() { assert_eq!(BONSAI_WATER_BONUS, 200); assert_eq!(CHIP_FLOOR, 100); + assert_eq!(INITIAL_CHIP_BALANCE, 1_000); } } diff --git a/late-core/src/models/mention_feed_read.rs b/late-core/src/models/mention_feed_read.rs index f7e0ac79..b3aca377 100644 --- a/late-core/src/models/mention_feed_read.rs +++ b/late-core/src/models/mention_feed_read.rs @@ -41,9 +41,17 @@ impl MentionFeedRead { .query_one( "SELECT COUNT(n.id)::bigint AS unread_count FROM notifications n + JOIN users recipient ON recipient.id = n.user_id LEFT JOIN mention_feed_reads mfr ON mfr.user_id = $1 WHERE n.user_id = $1 - AND n.created > COALESCE(mfr.last_read_at, '-infinity'::timestamptz)", + AND n.created > COALESCE(mfr.last_read_at, '-infinity'::timestamptz) + AND NOT ( + COALESCE(recipient.settings, '{}'::jsonb) + @> jsonb_build_object( + 'ignored_user_ids', + jsonb_build_array(n.actor_id::text) + ) + )", &[&user_id], ) .await?; diff --git a/late-core/src/models/notification.rs b/late-core/src/models/notification.rs index 619f878a..6ff2372f 100644 --- a/late-core/src/models/notification.rs +++ b/late-core/src/models/notification.rs @@ -45,26 +45,20 @@ impl Notification { return Ok(0); } - // Build a multi-row INSERT: ($1, $2, $3, $4), ($5, $2, $3, $4), ... - // where $2=actor_id, $3=message_id, $4=room_id are shared, and each $N is a user_id. - let mut params: Vec<&(dyn tokio_postgres::types::ToSql + Sync)> = - Vec::with_capacity(user_ids.len() + 3); - params.push(&actor_id); // $1 - params.push(&message_id); // $2 - params.push(&room_id); // $3 - - let mut value_clauses = Vec::with_capacity(user_ids.len()); - for (i, uid) in user_ids.iter().enumerate() { - params.push(uid); // $4, $5, $6, ... - value_clauses.push(format!("(${}, $1, $2, $3)", i + 4)); - } - - let query = format!( - "INSERT INTO notifications (user_id, actor_id, message_id, room_id) VALUES {} ON CONFLICT DO NOTHING", - value_clauses.join(", ") - ); - - let count = client.execute(&query, ¶ms).await?; + let count = client + .execute( + "INSERT INTO notifications (user_id, actor_id, message_id, room_id) + SELECT mentioned.user_id, $1, $2, $3 + FROM UNNEST($4::uuid[]) AS mentioned(user_id) + JOIN users recipient ON recipient.id = mentioned.user_id + WHERE NOT ( + COALESCE(recipient.settings, '{}'::jsonb) + @> jsonb_build_object('ignored_user_ids', jsonb_build_array(($1::uuid)::text)) + ) + ON CONFLICT DO NOTHING", + &[&actor_id, &message_id, &room_id, &user_ids], + ) + .await?; Ok(count) } @@ -82,9 +76,17 @@ impl Notification { LEFT(m.body, 120) AS message_preview FROM notifications n JOIN users u ON u.id = n.actor_id + JOIN users recipient ON recipient.id = n.user_id JOIN chat_rooms r ON r.id = n.room_id JOIN chat_messages m ON m.id = n.message_id WHERE n.user_id = $1 + AND NOT ( + COALESCE(recipient.settings, '{}'::jsonb) + @> jsonb_build_object( + 'ignored_user_ids', + jsonb_build_array(n.actor_id::text) + ) + ) AND r.kind <> 'game' AND ( r.kind = 'dm' @@ -120,9 +122,17 @@ impl Notification { "SELECT COUNT(n.id)::bigint AS unread_count FROM notifications n JOIN chat_rooms r ON r.id = n.room_id + JOIN users recipient ON recipient.id = n.user_id LEFT JOIN mention_feed_reads mfr ON mfr.user_id = $1 WHERE n.user_id = $1 AND n.created > COALESCE(mfr.last_read_at, '-infinity'::timestamptz) + AND NOT ( + COALESCE(recipient.settings, '{}'::jsonb) + @> jsonb_build_object( + 'ignored_user_ids', + jsonb_build_array(n.actor_id::text) + ) + ) AND r.kind <> 'game' AND ( r.kind = 'dm' @@ -169,6 +179,13 @@ impl Notification { ON m.room_id = r.id AND m.user_id = u.id \ WHERE LOWER(u.username) = ANY($1) \ AND u.id <> $2 \ + AND NOT ( + COALESCE(u.settings, '{}'::jsonb) + @> jsonb_build_object( + 'ignored_user_ids', + jsonb_build_array(($2::uuid)::text) + ) + ) \ AND r.kind <> 'game' \ AND ( (r.kind = 'dm' AND u.id IN (r.dm_user_a, r.dm_user_b)) diff --git a/late-core/tests/mention_feed_read.rs b/late-core/tests/mention_feed_read.rs index 5b2c3e35..88520166 100644 --- a/late-core/tests/mention_feed_read.rs +++ b/late-core/tests/mention_feed_read.rs @@ -4,6 +4,7 @@ use late_core::{ chat_room::ChatRoom, mention_feed_read::MentionFeedRead, notification::Notification, + user::User, }, test_utils::{create_test_user, test_db}, }; @@ -86,3 +87,111 @@ async fn mention_feed_unread_uses_timestamp_cursor() { .expect("count unread after new mention"); assert_eq!(unread_after_new, 1); } + +#[tokio::test] +async fn mention_notifications_skip_recipients_who_ignore_actor() { + let test_db = test_db().await; + let client = test_db.db.get().await.expect("db client"); + let room = ChatRoom::ensure_general(&client) + .await + .expect("ensure general"); + let actor = create_test_user(&test_db.db, "mention-ignored-actor").await; + let reader = create_test_user(&test_db.db, "mention-ignore-reader").await; + + User::add_ignored_user_id(&client, reader.id, actor.id) + .await + .expect("ignore actor"); + + let message = ChatMessage::create( + &client, + ChatMessageParams { + room_id: room.id, + user_id: actor.id, + body: "@mention-ignore-reader hello".to_string(), + }, + ) + .await + .expect("create mention message"); + + let inserted = + Notification::create_mentions_batch(&client, &[reader.id], actor.id, message.id, room.id) + .await + .expect("create mention notification"); + assert_eq!(inserted, 0); + + let list = Notification::list_for_user(&client, reader.id, 50) + .await + .expect("list notifications"); + assert!(list.is_empty()); + + let unread = Notification::unread_count(&client, reader.id) + .await + .expect("count notifications"); + assert_eq!(unread, 0); + let feed_unread = MentionFeedRead::unread_count_for_user(&client, reader.id) + .await + .expect("count feed notifications"); + assert_eq!(feed_unread, 0); +} + +#[tokio::test] +async fn mention_feed_hides_existing_notifications_after_actor_is_ignored() { + let test_db = test_db().await; + let client = test_db.db.get().await.expect("db client"); + let room = ChatRoom::ensure_general(&client) + .await + .expect("ensure general"); + let actor = create_test_user(&test_db.db, "mention-hide-actor").await; + let reader = create_test_user(&test_db.db, "mention-hide-reader").await; + + let message = ChatMessage::create( + &client, + ChatMessageParams { + room_id: room.id, + user_id: actor.id, + body: "@mention-hide-reader hello".to_string(), + }, + ) + .await + .expect("create mention message"); + Notification::create_mentions_batch(&client, &[reader.id], actor.id, message.id, room.id) + .await + .expect("create mention notification"); + + assert_eq!( + Notification::unread_count(&client, reader.id) + .await + .expect("count before ignore"), + 1 + ); + assert_eq!( + Notification::list_for_user(&client, reader.id, 50) + .await + .expect("list before ignore") + .len(), + 1 + ); + + User::add_ignored_user_id(&client, reader.id, actor.id) + .await + .expect("ignore actor"); + + assert_eq!( + Notification::unread_count(&client, reader.id) + .await + .expect("count after ignore"), + 0 + ); + assert_eq!( + MentionFeedRead::unread_count_for_user(&client, reader.id) + .await + .expect("count feed after ignore"), + 0 + ); + assert!( + Notification::list_for_user(&client, reader.id, 50) + .await + .expect("list after ignore") + .is_empty() + ); +} diff --git a/late-ssh/src/app/arcade/CONTEXT.md b/late-ssh/src/app/arcade/CONTEXT.md index b7cbb99d..ea6a0674 100644 --- a/late-ssh/src/app/arcade/CONTEXT.md +++ b/late-ssh/src/app/arcade/CONTEXT.md @@ -2,7 +2,7 @@ ## Metadata - Scope: `late-ssh/src/app/arcade` -- Last updated: 2026-05-13 +- Last updated: 2026-05-14 - Purpose: local working context for The Arcade screen, single-player terminal games, and shared card/chip helpers. - Parent context: `../../../../CONTEXT.md` @@ -14,7 +14,7 @@ Hub/leaderboard surfaces are separate and live under `late-ssh/src/app/hub`. Arc Rooms/table games are separate and live under `late-ssh/src/app/rooms`, but they intentionally reuse shared Arcade support modules: - `cards.rs` for card ranks/suits/rendering. -- `chips/svc.rs` for Late Chips balances, stipends, debits, payouts, floors, and daily bonuses. +- `chips/svc.rs` for Late Chips balances, initial grants, debits, payouts, floors, and daily bonuses. - `ui.rs` for shared framed game drawing helpers. Keep `mod.rs` declaration-only. Do not add `pub use` re-export layers. @@ -111,7 +111,7 @@ Testing guidance: - High-score services keep SQL inside `late-core` models. `late-ssh` services call model methods such as `HighScore::update_score_if_higher` and `HighScore::record_score_event`; do not insert score-event SQL directly from Arcade services. - Daily puzzle services store board progress by `(user_id, difficulty_key, mode)`. - Daily win tables record one completion fact per user/date/difficulty, separate from board state. -- `ChipService::ensure_chips(user_id)` grants the daily 500-chip stipend on login. +- `ChipService::ensure_chips(user_id)` creates new chip rows with 1000 chips. - `ChipService::grant_daily_bonus_task(user_id, difficulty_key)` awards 50/100/150 chips for daily puzzle completions. - Daily services call `record_win_task()` on completion. That records the daily win, grants chips, and publishes a structured Activity event. - `hub::svc::LeaderboardService` refreshes from DB every 30s. Immediate win callouts come from Activity; Hub leaderboard surfaces lag until the next refresh. diff --git a/late-ssh/src/app/arcade/chips/svc.rs b/late-ssh/src/app/arcade/chips/svc.rs index 5dae4388..0174848a 100644 --- a/late-ssh/src/app/arcade/chips/svc.rs +++ b/late-ssh/src/app/arcade/chips/svc.rs @@ -12,8 +12,7 @@ impl ChipService { Self { db } } - /// Ensure a chips row exists and grant the daily stipend if not already granted today. - /// Called on SSH login. + /// Ensure a chips row exists for the user. Called on SSH login. pub async fn ensure_chips(&self, user_id: Uuid) -> anyhow::Result { let client = self.db.get().await?; UserChips::ensure(&client, user_id).await diff --git a/late-ssh/src/app/chat/CONTEXT.md b/late-ssh/src/app/chat/CONTEXT.md index 5a4b7a27..ff166d2d 100644 --- a/late-ssh/src/app/chat/CONTEXT.md +++ b/late-ssh/src/app/chat/CONTEXT.md @@ -3,7 +3,7 @@ ## Metadata - Domain: late.sh SSH chat, synthetic chat entries, and dashboard/room chat surfaces - Primary audience: LLM agents working in `late-ssh/src/app/chat` -- Last updated: 2026-05-13 +- Last updated: 2026-05-14 - Status: Active - Parent context: `../../../../CONTEXT.md` @@ -138,7 +138,7 @@ Reactions: Notifications: - Mentions are stored in `notifications`. - Mention unread state is cursor-based through `mention_feed_reads`. -- Mention resolution excludes the actor; DMs only notify DM participants, private rooms only members, and non-game public rooms may mention any user. Game-room chat does not create Mentions feed notifications. +- Mention resolution excludes the actor and recipients who ignore the actor; DMs only notify DM participants, private rooms only members, and non-game public rooms may mention any user. Game-room chat does not create Mentions feed notifications. --- @@ -218,8 +218,8 @@ Starting compose in a room: Submit flow in `ChatState::submit_composer`: - Commands are handled before normal send. -- `/leave` and `/invite` are refused from Home contexts where the target cannot be resolved safely. -- `/members` resolves the target before clearing the composer because clearing removes `composer_room_id`. +- `/leave` and `/invite` resolve through the active composer room or selected real room. Synthetic entries do not fall back to stale `selected_room_id` values; `/leave` on a selected synthetic entry exits that entry back to the last real room. +- `/members` uses the same real-room resolver as `/leave` and `/invite`. - Normal send calls `send_message_with_reply_task`. - Edit calls `edit_message_task`. - Enter submits and closes. @@ -342,7 +342,7 @@ Ignores: - `/ignore @user` and `/unignore @user` resolve usernames at command time. - Ignore filtering applies to non-DM rooms only. - DMs intentionally bypass ignored-user filtering; leaving the DM room is the dismissal path. -- `IgnoreListUpdated` refilters local non-DM messages in place with no DB refetch. +- `IgnoreListUpdated` refilters local non-DM messages in place with no DB refetch, then refreshes the Mentions list/unread count. - `unignore` does not retroactively restore already-filtered local messages until a future tail/snapshot naturally reloads them. --- @@ -389,6 +389,7 @@ Synthetic entries are selected from the room list but are not normal `ChatRoom`s - Backed by `notifications` joined with actor, room, and message preview data. - Snapshot is user-targeted; consumers must ignore snapshots where `snapshot.user_id != current_user`. +- List and unread queries exclude notifications whose actor is in `users.settings.ignored_user_ids`. - Selecting Mentions lists notifications and marks all read optimistically; re-selecting Mentions through room-jump or mouse does the same. - Enter jumps to the referenced room/message when possible. diff --git a/late-ssh/src/app/chat/news/state.rs b/late-ssh/src/app/chat/news/state.rs index ce01f651..7067de5e 100644 --- a/late-ssh/src/app/chat/news/state.rs +++ b/late-ssh/src/app/chat/news/state.rs @@ -289,6 +289,19 @@ impl State { } } + pub fn composer_cursor_home(&mut self) { + if !self.processing { + self.composer + .move_cursor(ratatui_textarea::CursorMove::Head); + } + } + + pub fn composer_cursor_end(&mut self) { + if !self.processing { + self.composer.move_cursor(ratatui_textarea::CursorMove::End); + } + } + pub fn delete_selected(&mut self) { if let Some(item) = self.articles.get(self.selected_index()) { let is_owner = item.article.user_id == self.user_id; diff --git a/late-ssh/src/app/chat/notifications/state.rs b/late-ssh/src/app/chat/notifications/state.rs index 4327c6a0..50d88fda 100644 --- a/late-ssh/src/app/chat/notifications/state.rs +++ b/late-ssh/src/app/chat/notifications/state.rs @@ -47,6 +47,10 @@ impl State { self.service.list_task(self.user_id); } + pub fn refresh_unread_count(&self) { + self.service.refresh_unread_count_task(self.user_id); + } + pub fn selected_index(&self) -> usize { clamp_index(self.selected, self.items.len()) } diff --git a/late-ssh/src/app/chat/state.rs b/late-ssh/src/app/chat/state.rs index e647ea5a..b30ca987 100644 --- a/late-ssh/src/app/chat/state.rs +++ b/late-ssh/src/app/chat/state.rs @@ -162,6 +162,28 @@ pub(crate) fn is_selected_slot(slot: RoomSlot, selected: SelectedRoomSlotState) } } +fn synthetic_entry_selected(selected: SelectedRoomSlotState) -> bool { + selected.feeds_selected + || selected.news_selected + || selected.notifications_selected + || selected.discover_selected + || selected.showcase_selected + || selected.work_selected +} + +fn room_membership_command_target( + composer_room_id: Option, + selected: SelectedRoomSlotState, +) -> Option { + composer_room_id.or_else(|| { + if synthetic_entry_selected(selected) { + None + } else { + selected.selected_room_id + } + }) +} + pub(crate) fn is_chat_list_room(room: &ChatRoom) -> bool { if room.kind == "game" { return false; @@ -891,16 +913,63 @@ impl ChatState { room_slug_for(&self.rooms, room_id) } - fn selected_room_slug(&self) -> Option { - self.selected_room().and_then(|room| room.slug.clone()) + fn room_membership_command_target(&self) -> Option { + room_membership_command_target(self.composer_room_id, self.selected_slot_state()) } - fn selected_room(&self) -> Option<&ChatRoom> { - let room_id = self.selected_room_id?; - self.rooms - .iter() - .find(|(room, _)| room.id == room_id) - .map(|(room, _)| room) + fn selected_slot_state(&self) -> SelectedRoomSlotState { + SelectedRoomSlotState { + selected_room_id: self.selected_room_id, + feeds_selected: self.feeds_selected, + news_selected: self.news_selected, + notifications_selected: self.notifications_selected, + discover_selected: self.discover_selected, + showcase_selected: self.showcase_selected, + work_selected: self.work_selected, + } + } + + fn selected_synthetic_entry_label(&self) -> Option<&'static str> { + if self.news_selected { + Some("news") + } else if self.feeds_selected { + Some("rss") + } else if self.notifications_selected { + Some("mentions") + } else if self.discover_selected { + Some("browse rooms") + } else if self.showcase_selected { + Some("showcase") + } else if self.work_selected { + Some("work") + } else { + None + } + } + + fn leave_selected_synthetic_entry(&mut self) -> Option<&'static str> { + let label = self.selected_synthetic_entry_label()?; + self.feeds_selected = false; + self.news_selected = false; + self.notifications_selected = false; + self.discover_selected = false; + self.showcase_selected = false; + self.work_selected = false; + + if self.selected_room_id.is_none() { + self.selected_room_id = self + .rooms + .iter() + .find(|(room, _)| is_chat_list_room(room)) + .map(|(room, _)| room.id); + } + if let Some(room_id) = self.selected_room_id { + self.visible_room_id = Some(room_id); + self.mark_room_read(room_id); + self.request_room_tail(room_id); + } + + Some(label) } pub fn general_room_id(&self) -> Option { @@ -1229,26 +1298,9 @@ impl ChatState { self.open_overlay("Active Users", self.active_user_lines()); } - pub fn submit_composer(&mut self, keep_open: bool, from_dashboard: bool) -> Option { + pub fn submit_composer(&mut self, keep_open: bool, _from_dashboard: bool) -> Option { let body = self.composer.lines().join("\n").trim_end().to_string(); - // Room-membership commands are intentionally chat-page-only: they - // operate on `selected_room_id`, which the dashboard never drives. - // Rather than silently target the wrong room, refuse here and point - // the user at page 2. - if from_dashboard && parse_leave_command(&body) { - self.clear_composer_after_submit(); - return Some(Banner::error( - "open the chat page (press 2) to leave a room", - )); - } - if from_dashboard && parse_user_command(&body, "/invite").is_some() { - self.clear_composer_after_submit(); - return Some(Banner::error( - "open the chat page (press 2) to invite a user", - )); - } - if body.trim() == "/binds" { self.clear_composer_after_submit(); self.requested_help_topic = Some(HelpTopic::Chat); @@ -1328,14 +1380,13 @@ impl ChatState { } if body.trim() == "/members" { - // Resolve the target room BEFORE clearing the composer — - // `clear_composer_after_submit` nulls `composer_room_id`, so - // reading after would always fall back to the chat-page - // `selected_room_id` and miss the dashboard's active favorite. - let target = self.composer_room_id.or(self.selected_room_id); + // Resolve the target room BEFORE clearing the composer. + // Synthetic entries can retain a stale `selected_room_id`, so + // membership commands must go through the shared resolver. + let target = self.room_membership_command_target(); self.clear_composer_after_submit(); let Some(room_id) = target else { - return Some(Banner::error("no room selected")); + return Some(Banner::error("No member-list room selected")); }; self.service.list_room_members_task(self.user_id, room_id); return None; @@ -1395,9 +1446,10 @@ impl ChatState { } if let Some(target) = parse_user_command(&body, "/invite") { + let room_id = self.room_membership_command_target(); self.clear_composer_after_submit(); - let Some(room_id) = self.selected_room_id else { - return Some(Banner::error("No room selected")); + let Some(room_id) = room_id else { + return Some(Banner::error("No inviteable room selected")); }; let Some(target) = target else { return Some(Banner::error("Usage: /invite @user")); @@ -1408,14 +1460,19 @@ impl ChatState { } if parse_leave_command(&body) { + let target = self.room_membership_command_target(); + let slug = target + .and_then(|room_id| self.room_slug(room_id)) + .unwrap_or_else(|| "room".to_string()); self.clear_composer_after_submit(); - if let Some(room_id) = self.selected_room_id { - let slug = self.selected_room_slug().unwrap_or_default(); + if let Some(room_id) = target { self.service .leave_room_task(self.user_id, room_id, slug.clone()); return Some(Banner::success(&format!("Leaving #{slug}..."))); + } else if let Some(label) = self.leave_selected_synthetic_entry() { + return Some(Banner::success(&format!("Left #{label}"))); } else { - return Some(Banner::error("No room selected")); + return Some(Banner::error("No leaveable room selected")); } } @@ -1539,6 +1596,14 @@ impl ChatState { self.composer.move_cursor(CursorMove::WordForward); } + pub fn composer_cursor_home(&mut self) { + self.composer.move_cursor(CursorMove::Head); + } + + pub fn composer_cursor_end(&mut self) { + self.composer.move_cursor(CursorMove::End); + } + pub fn composer_cursor_up(&mut self) { self.composer.move_cursor(CursorMove::Up); } @@ -2128,7 +2193,12 @@ impl ChatState { // Desktop notification queueing. target_user_ids is Some for // DM/private rooms, None for public rooms. Don't notify on // messages we authored ourselves. - if message.user_id != self.user_id { + let in_dm_room = self + .rooms + .iter() + .any(|(room, _)| room.id == message.room_id && room.kind == "dm"); + let ignored_author = !in_dm_room && self.message_is_ignored(&message); + if message.user_id != self.user_id && !ignored_author { let nickname = self .usernames .get(&message.user_id) @@ -2370,6 +2440,8 @@ impl ChatState { } if self.user_id == user_id => { self.ignored_user_ids = ignored_user_ids.into_iter().collect(); self.refilter_local_messages(); + self.notifications.list(); + self.notifications.refresh_unread_count(); banner = Some(Banner::success(&message)); } ChatEvent::IgnoreFailed { user_id, message } if self.user_id == user_id => { @@ -3888,6 +3960,34 @@ mod tests { assert_eq!(adjacent_composer_room(&order, None, 1), None); } + #[test] + fn room_membership_command_target_ignores_stale_real_room_for_synthetic_entries() { + let stale_room = Uuid::from_u128(1); + let selected = SelectedRoomSlotState { + selected_room_id: Some(stale_room), + news_selected: true, + ..SelectedRoomSlotState::default() + }; + + assert_eq!(room_membership_command_target(None, selected), None); + } + + #[test] + fn room_membership_command_target_prefers_active_composer_room() { + let stale_room = Uuid::from_u128(1); + let composer_room = Uuid::from_u128(2); + let selected = SelectedRoomSlotState { + selected_room_id: Some(stale_room), + news_selected: true, + ..SelectedRoomSlotState::default() + }; + + assert_eq!( + room_membership_command_target(Some(composer_room), selected), + Some(composer_room) + ); + } + #[test] fn room_slug_for_uses_explicit_room_id() { let general_id = Uuid::from_u128(11); diff --git a/late-ssh/src/app/chat/ui.rs b/late-ssh/src/app/chat/ui.rs index 8f4ea806..992e6ad7 100644 --- a/late-ssh/src/app/chat/ui.rs +++ b/late-ssh/src/app/chat/ui.rs @@ -1428,12 +1428,7 @@ pub(crate) fn room_list_hit_test( return None; } - let inner = Rect { - x: rooms_area.x + 2, - y: rooms_area.y + 1, - width: rooms_area.width.saturating_sub(4), - height: rooms_area.height.saturating_sub(1), - }; + let inner = room_rail_inner_area(rooms_area); let hint_rows = build_rail_nav_hint_lines().len() as u16; let footer_reserve = hint_rows + 2; let list_area = if inner.height > footer_reserve + 2 { @@ -1481,12 +1476,7 @@ pub fn draw_room_list_rail(frame: &mut Frame, area: Rect, view: &ChatRenderInput // Content lives inside: 2 cols left padding, 2 cols right (separator + 1). // Bottom slice is reserved for the pinned nav-hint footer. - let inner = Rect { - x: area.x + 2, - y: area.y + 1, - width: area.width.saturating_sub(4), - height: area.height.saturating_sub(1), - }; + let inner = room_rail_inner_area(area); let hint_lines = build_rail_nav_hint_lines(); let hint_rows = hint_lines.len() as u16; @@ -1532,13 +1522,15 @@ pub fn draw_room_list_rail(frame: &mut Frame, area: Rect, view: &ChatRenderInput } // Strip the sentinel marker span before rendering text. + let mut shifted_invite_rows = Vec::new(); let display_lines: Vec> = room_rows .lines .into_iter() .skip(scroll) .take(visible_height) - .map(|line| { - if line + .enumerate() + .map(|(idx, line)| { + let line = if line .spans .first() .is_some_and(|s| s.content.as_ref() == "▌") @@ -1546,11 +1538,28 @@ pub fn draw_room_list_rail(frame: &mut Frame, area: Rect, view: &ChatRenderInput Line::from(line.spans.into_iter().skip(1).collect::>()) } else { line + }; + + if line_text(&line) == VOICE_DISCORD_INVITE { + shifted_invite_rows.push(idx); + Line::raw("") + } else { + line } }) .collect(); frame.render_widget(Paragraph::new(display_lines), list_area); + for idx in shifted_invite_rows { + let invite_area = shifted_voice_invite_area(list_area, idx as u16); + frame.render_widget( + Paragraph::new(Line::from(Span::styled( + VOICE_DISCORD_INVITE, + Style::default().fg(theme::TEXT_DIM()), + ))), + invite_area, + ); + } if let Some(hint_area) = hint_area { let buf = frame.buffer_mut(); @@ -1576,6 +1585,31 @@ pub fn draw_room_list_rail(frame: &mut Frame, area: Rect, view: &ChatRenderInput } } +fn room_rail_inner_area(area: Rect) -> Rect { + Rect { + x: area.x + 2, + y: area.y + 1, + width: area.width.saturating_sub(4), + height: area.height.saturating_sub(1), + } +} + +fn shifted_voice_invite_area(list_area: Rect, row_offset: u16) -> Rect { + Rect { + x: list_area.x.saturating_sub(1), + y: list_area.y + row_offset, + width: list_area.width.saturating_add(1), + height: 1, + } +} + +fn line_text(line: &Line<'_>) -> String { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect() +} + /// Builds the cozy rail rows. Active rows are tagged with a sentinel `▌` span /// at index 0 so the renderer can paint a one-column accent bar in the gutter. /// That sentinel is stripped before final paint. @@ -2815,12 +2849,7 @@ mod tests { let area = Rect::new(1, 1, 74, 30); let rooms_area = room_list_area(area, chat_selection_mode(&view, area)); let room_list_view = room_list_view_from_render_input(&view); - let inner = Rect { - x: rooms_area.x + 2, - y: rooms_area.y + 1, - width: rooms_area.width.saturating_sub(4), - height: rooms_area.height.saturating_sub(1), - }; + let inner = room_rail_inner_area(rooms_area); let hint_rows = build_rail_nav_hint_lines().len() as u16; let footer_reserve = hint_rows + 2; let list_area = if inner.height > footer_reserve + 2 { @@ -2863,4 +2892,18 @@ mod tests { rooms_area.y )); } + + #[test] + fn cozy_room_rail_shifts_only_discord_invite_into_gutter() { + let area = Rect::new(0, 0, 24, 20); + let inner = room_rail_inner_area(area); + let invite_area = shifted_voice_invite_area(inner, 0); + + assert!(UnicodeWidthStr::width(VOICE_DISCORD_INVITE) > inner.width as usize); + assert!( + UnicodeWidthStr::width(VOICE_DISCORD_INVITE) <= invite_area.width as usize, + "invite should fit the fixed Home rail without widening it" + ); + assert_eq!(invite_area.x, inner.x - 1); + } } diff --git a/late-ssh/src/app/help_modal/data.rs b/late-ssh/src/app/help_modal/data.rs index 664ee62a..fdc45da9 100644 --- a/late-ssh/src/app/help_modal/data.rs +++ b/late-ssh/src/app/help_modal/data.rs @@ -143,7 +143,6 @@ pub fn chat_help_lines() -> Vec { " ↑ / ↓ same as j / k", " Ctrl+U / Ctrl+D half page up / down", " PageUp / PageDown half page up / down", - " End jump to most recent", " g / G clear selection (back to live view)", " p open selected user's profile", " f then 1-8", @@ -338,7 +337,6 @@ fn rooms_help_lines() -> Vec { " i compose in embedded chat", " j / k embedded-chat message selection unless game claims the key", " PageUp/PageDown scroll embedded chat", - " End jump embedded chat to most recent", " r/e/d/p/c/f reply, edit, delete, profile, copy, react selected chat message", " Arrows game gets first chance; otherwise embedded chat handles them", "", diff --git a/late-ssh/src/app/input.rs b/late-ssh/src/app/input.rs index a538af6f..d859f0a7 100644 --- a/late-ssh/src/app/input.rs +++ b/late-ssh/src/app/input.rs @@ -323,10 +323,8 @@ impl Perform for VtCollector { self.events.push(ParsedInput::CtrlBackspace); } // PageUp / PageDown / End (numeric form: CSI n ~). rxvt/linux - // console encode End as 4~; xterm uses 8~. Home is intentionally - // not bound — jumping to the oldest message in a long-lived room - // is rarely useful and the `End` / PageUp pair covers the real - // "scroll to a specific position" need. + // console encode End as 4~; xterm uses 8~. Home is parsed below + // for text inputs and surfaces that opt into it. '~' if p0 == Some(5) => self.events.push(ParsedInput::PageUp), '~' if p0 == Some(6) => self.events.push(ParsedInput::PageDown), '~' if p0 == Some(4) || p0 == Some(8) => self.events.push(ParsedInput::End), @@ -830,11 +828,59 @@ fn handle_parsed_input(app: &mut App, event: ParsedInput) { let step = (app.size.1 / 6).max(1) as isize; handle_scroll_for_screen(app, ctx.screen, -step); } - ParsedInput::End => { - if room_jump_active_on_current_screen(app, ctx.screen) { - return; - } - handle_scroll_for_screen(app, ctx.screen, isize::MIN) + ParsedInput::Home if is_chat_composer_context(ctx) => { + app.chat.composer_cursor_home(); + app.chat.update_autocomplete(); + } + ParsedInput::End if is_chat_composer_context(ctx) => { + app.chat.composer_cursor_end(); + app.chat.update_autocomplete(); + } + ParsedInput::Home if ctx.screen == Screen::Dashboard && ctx.news_composing => { + app.chat.news.composer_cursor_home(); + } + ParsedInput::End if ctx.screen == Screen::Dashboard && ctx.news_composing => { + app.chat.news.composer_cursor_end(); + } + ParsedInput::Home if ctx.screen == Screen::Dashboard && ctx.showcase_composing => { + let field = app.chat.showcase.active_field(); + app.chat.showcase.field_input( + field, + ratatui_textarea::Input { + key: ratatui_textarea::Key::Home, + ..Default::default() + }, + ); + } + ParsedInput::End if ctx.screen == Screen::Dashboard && ctx.showcase_composing => { + let field = app.chat.showcase.active_field(); + app.chat.showcase.field_input( + field, + ratatui_textarea::Input { + key: ratatui_textarea::Key::End, + ..Default::default() + }, + ); + } + ParsedInput::Home if ctx.screen == Screen::Dashboard && ctx.work_composing => { + let field = app.chat.work.active_field(); + app.chat.work.field_input( + field, + ratatui_textarea::Input { + key: ratatui_textarea::Key::Home, + ..Default::default() + }, + ); + } + ParsedInput::End if ctx.screen == Screen::Dashboard && ctx.work_composing => { + let field = app.chat.work.active_field(); + app.chat.work.field_input( + field, + ratatui_textarea::Input { + key: ratatui_textarea::Key::End, + ..Default::default() + }, + ); } ParsedInput::Delete if is_chat_composer_context(ctx) => { app.chat.composer_delete_right(); @@ -909,7 +955,10 @@ fn handle_parsed_input(app: &mut App, event: ParsedInput) { | ParsedInput::CtrlDelete => {} // Modified arrows are only bound on screens that opt in via the early // `handle_event` hook. Everywhere else they're inert. - ParsedInput::ShiftArrow(_) | ParsedInput::CtrlShiftArrow(_) | ParsedInput::Home => {} + ParsedInput::ShiftArrow(_) + | ParsedInput::CtrlShiftArrow(_) + | ParsedInput::Home + | ParsedInput::End => {} ParsedInput::Arrow(key) => { if room_jump_active_on_current_screen(app, ctx.screen) { let _ = chat::input::handle_arrow(app, key); @@ -2042,6 +2091,8 @@ fn handle_icon_picker_input(app: &mut App, event: ParsedInput) { ParsedInput::CtrlArrow(b'D') | ParsedInput::AltArrow(b'D') => { app.icon_picker_state.search_cursor_word_left() } + ParsedInput::Home => app.icon_picker_state.search_cursor_home(), + ParsedInput::End => app.icon_picker_state.search_cursor_end(), ParsedInput::PageUp => { let page = app.icon_picker_state.visible_height.get().max(1) as isize; picker_move_selection(app, -page); diff --git a/late-ssh/src/app/rooms/input.rs b/late-ssh/src/app/rooms/input.rs index 00f3aee5..e047a776 100644 --- a/late-ssh/src/app/rooms/input.rs +++ b/late-ssh/src/app/rooms/input.rs @@ -26,7 +26,6 @@ pub(crate) fn handle_event(app: &mut App, event: &ParsedInput) -> bool { ParsedInput::PageDown => { return handle_active_room_scroll(app, -active_room_page_step(app)); } - ParsedInput::End => return handle_active_room_scroll(app, isize::MIN), ParsedInput::Mouse(mouse) => match mouse.kind { MouseEventKind::ScrollUp => return handle_active_room_scroll(app, 1), MouseEventKind::ScrollDown => return handle_active_room_scroll(app, -1), diff --git a/late-ssh/src/app/settings_modal/input.rs b/late-ssh/src/app/settings_modal/input.rs index 9a660dc5..ee473ad4 100644 --- a/late-ssh/src/app/settings_modal/input.rs +++ b/late-ssh/src/app/settings_modal/input.rs @@ -273,6 +273,8 @@ fn handle_system_input(app: &mut App, event: ParsedInput) { ParsedInput::Byte(0x15) => state.clear_system_field(), ParsedInput::Byte(0x01) => state.system_cursor_home(), ParsedInput::Byte(0x05) => state.system_cursor_end(), + ParsedInput::Home => state.system_cursor_home(), + ParsedInput::End => state.system_cursor_end(), ParsedInput::Byte(0x19) => state.system_paste(), ParsedInput::Byte(0x1F) => state.system_undo(), ParsedInput::Byte(0x7F) => state.system_backspace(), @@ -311,6 +313,8 @@ fn handle_username_input(app: &mut App, event: ParsedInput) { ParsedInput::Byte(0x15) => state.clear_username(), ParsedInput::Byte(0x01) => state.username_cursor_home(), ParsedInput::Byte(0x05) => state.username_cursor_end(), + ParsedInput::Home => state.username_cursor_home(), + ParsedInput::End => state.username_cursor_end(), ParsedInput::Byte(0x19) => state.username_paste(), ParsedInput::Byte(0x1F) => state.username_undo(), ParsedInput::Byte(0x7F) => state.username_backspace(), @@ -355,6 +359,8 @@ fn handle_delete_account_dialog_input(app: &mut App, event: ParsedInput) { ParsedInput::Byte(0x15) => state.clear_delete_account_confirmation(), ParsedInput::Byte(0x01) => state.delete_account_cursor_home(), ParsedInput::Byte(0x05) => state.delete_account_cursor_end(), + ParsedInput::Home => state.delete_account_cursor_home(), + ParsedInput::End => state.delete_account_cursor_end(), ParsedInput::Byte(0x7F) => state.delete_account_backspace(), ParsedInput::Delete => state.delete_account_delete_right(), ParsedInput::CtrlBackspace | ParsedInput::Byte(0x08) => { @@ -393,6 +399,8 @@ fn handle_feed_url_input(app: &mut App, event: ParsedInput) { ParsedInput::Byte(0x15) => state.feed_clear(), ParsedInput::Byte(0x01) => state.feed_cursor_home(), ParsedInput::Byte(0x05) => state.feed_cursor_end(), + ParsedInput::Home => state.feed_cursor_home(), + ParsedInput::End => state.feed_cursor_end(), ParsedInput::Byte(0x19) => state.feed_paste(), ParsedInput::Byte(0x1F) => state.feed_undo(), ParsedInput::Byte(0x7F) => state.feed_backspace(), @@ -439,6 +447,8 @@ fn handle_bio_input(app: &mut App, event: ParsedInput) { ParsedInput::Arrow(b'D') => state.bio_cursor_left(), ParsedInput::CtrlArrow(b'C') | ParsedInput::AltArrow(b'C') => state.bio_cursor_word_right(), ParsedInput::CtrlArrow(b'D') | ParsedInput::AltArrow(b'D') => state.bio_cursor_word_left(), + ParsedInput::Home => state.bio_cursor_home(), + ParsedInput::End => state.bio_cursor_end(), ParsedInput::Paste(pasted) => { let cleaned = sanitize_paste_markers(&String::from_utf8_lossy(&pasted)); let normalized = cleaned.replace("\r\n", "\n").replace('\r', "\n"); diff --git a/late-ssh/src/app/settings_modal/state.rs b/late-ssh/src/app/settings_modal/state.rs index 7b3c9a7f..f346e025 100644 --- a/late-ssh/src/app/settings_modal/state.rs +++ b/late-ssh/src/app/settings_modal/state.rs @@ -1086,6 +1086,14 @@ impl SettingsModalState { self.bio_input.move_cursor(CursorMove::WordForward); } + pub fn bio_cursor_home(&mut self) { + self.bio_input.move_cursor(CursorMove::Head); + } + + pub fn bio_cursor_end(&mut self) { + self.bio_input.move_cursor(CursorMove::End); + } + pub fn bio_paste(&mut self) { let yank = self.bio_input.yank_text(); insert_bio_text_limited(&mut self.bio_input, &yank); diff --git a/late-ssh/src/session_bootstrap.rs b/late-ssh/src/session_bootstrap.rs index 190c4dd2..81df4d1f 100644 --- a/late-ssh/src/session_bootstrap.rs +++ b/late-ssh/src/session_bootstrap.rs @@ -127,7 +127,7 @@ pub async fn build_session_config(state: &State, inputs: SessionBootstrapInputs) let initial_chip_balance = match state.chip_service.ensure_chips(user_id).await { Ok(chips) => chips.balance, Err(e) => { - tracing::warn!(error = ?e, "failed to grant daily chip stipend"); + tracing::warn!(error = ?e, "failed to ensure chip balance"); 0 } }; diff --git a/late-ssh/src/ssh.rs b/late-ssh/src/ssh.rs index 439a54bc..c49bdd16 100644 --- a/late-ssh/src/ssh.rs +++ b/late-ssh/src/ssh.rs @@ -786,11 +786,11 @@ impl russh::server::Handler for ClientHandler { } }; - // Grant daily chip stipend on login + // Ensure the user's chip balance row exists. let initial_chip_balance = match self.state.chip_service.ensure_chips(user_id).await { Ok(chips) => chips.balance, Err(e) => { - tracing::warn!(error = ?e, "failed to grant daily chip stipend"); + tracing::warn!(error = ?e, "failed to ensure chip balance"); 0 } };