Skip to content
Open
2 changes: 1 addition & 1 deletion CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ This file is the primary working context for the entire late.sh project.
The system is a Rust workspace with four crates (`late-cli`, `late-core`, `late-ssh`, `late-web`) backed by PostgreSQL, Icecast audio streaming, and Liquidsoap playlist management.

- **Primary entry points:** SSH server (russh on port 2222), HTTP API (axum on port 4000), Web server (axum on port 3000)
- **Main responsibilities:** Multi-screen TUI over SSH (Home/Dashboard, The Arcade, Rooms, Artboard), public web frontend, genre voting, paired browser/CLI audio control plus visualizer, real-time chat and chat-adjacent surfaces inside Home, private per-user RSS/Atom inboxes that can be shared into News, link/YouTube sharing with AI summaries/ASCII thumbnails, Arcade games, persistent game-backed Rooms, a shared multi-user ASCII Artboard, a global Hub modal for leaderboard/dailies/shop/events surfaces, and one structured global Activity stream for user actions. Detailed CLI behavior lives in `late-cli/CONTEXT.md`; detailed Web behavior lives in `late-web/CONTEXT.md`; detailed Arcade behavior lives in `late-ssh/src/app/arcade/CONTEXT.md`; detailed Rooms/Blackjack behavior lives in `late-ssh/src/app/rooms/CONTEXT.md`; detailed Chat behavior lives in `late-ssh/src/app/chat/CONTEXT.md`; detailed Artboard/dartboard behavior lives in `late-ssh/src/app/artboard/CONTEXT.md`. Configurable Home layout surfaces: the global right sidebar (time, visualizer, hot rooms, bonsai), the Home room-list rail, and the general-room lounge info strip (top boxes plus wire); `v` then `v` cycles persisted combinations of those panels. Global `q` opens quit confirm; pressing `q` again exits and `Esc` dismisses it.
- **Main responsibilities:** Multi-screen TUI over SSH (Home/Dashboard, The Arcade, Rooms, Artboard), public web frontend, genre voting, paired browser/CLI audio control plus visualizer, real-time chat and chat-adjacent surfaces inside Home, private per-user RSS/Atom inboxes that can be shared into News, link/YouTube sharing with AI summaries/ASCII thumbnails, Arcade games, persistent game-backed Rooms, a shared multi-user ASCII Artboard, a global Hub modal for leaderboard/dailies/shop/events surfaces, and one structured global Activity stream for user actions. Detailed CLI behavior lives in `late-cli/CONTEXT.md`; detailed Web behavior lives in `late-web/CONTEXT.md`; detailed Arcade behavior lives in `late-ssh/src/app/arcade/CONTEXT.md`; detailed Rooms/Blackjack behavior lives in `late-ssh/src/app/rooms/CONTEXT.md`; detailed Chat behavior lives in `late-ssh/src/app/chat/CONTEXT.md`; detailed Artboard/dartboard behavior lives in `late-ssh/src/app/artboard/CONTEXT.md`. Configurable Home layout surfaces: the global right sidebar (time, visualizer, hot rooms, bonsai) with on/off/custom per-screen visibility, the Home room-list rail, and the general-room lounge info strip (top boxes plus wire); `v` then `v` cycles persisted combinations of those panels. Global `q` opens quit confirm; pressing `q` again exits and `Esc` dismisses it.
- **Highest-risk areas:** SSH render loop backpressure, connection limiting, chat sync consistency, paired-client WS routing/state drift

---
Expand Down
61 changes: 46 additions & 15 deletions late-core/src/models/profile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@ use tokio_postgres::Client;
use uuid::Uuid;

use super::user::{
User, extract_bio, extract_country, extract_enable_background_color, extract_favorite_room_ids,
extract_ide, extract_langs, extract_notify_bell, extract_notify_cooldown_mins,
extract_notify_format, extract_notify_kinds, extract_os, extract_show_dashboard_header,
extract_show_dashboard_wire, extract_show_right_sidebar, extract_show_room_list_sidebar,
extract_show_settings_on_connect, extract_terminal, extract_theme_id, extract_timezone,
RIGHT_SIDEBAR_SCREEN_COUNT, RightSidebarMode, User, extract_bio, extract_country,
extract_enable_background_color, extract_favorite_room_ids, extract_ide, extract_langs,
extract_notify_bell, extract_notify_cooldown_mins, extract_notify_format, extract_notify_kinds,
extract_os, extract_right_sidebar_mode, extract_right_sidebar_screens,
extract_show_dashboard_header, extract_show_dashboard_wire, extract_show_right_sidebar,
extract_show_room_list_sidebar, extract_show_settings_on_connect, extract_terminal,
extract_theme_id, extract_timezone,
};

#[derive(Clone, Debug)]
Expand All @@ -35,6 +37,11 @@ pub struct Profile {
/// Controls the general-room dashboard wire strip.
pub show_dashboard_wire: bool,
pub show_right_sidebar: bool,
pub right_sidebar_mode: RightSidebarMode,
/// Per-screen visibility when `right_sidebar_mode == Custom`. Each entry is
/// a 1-based screen index in `1..=RIGHT_SIDEBAR_SCREEN_COUNT`
/// (Dashboard=1, Arcade=2, Rooms=3, Artboard=4).
pub right_sidebar_screens: Vec<u8>,
pub show_room_list_sidebar: bool,
/// When false, the settings modal is not auto-opened on connect.
pub show_settings_on_connect: bool,
Expand Down Expand Up @@ -63,6 +70,8 @@ impl Default for Profile {
show_dashboard_header: true,
show_dashboard_wire: true,
show_right_sidebar: true,
right_sidebar_mode: RightSidebarMode::On,
right_sidebar_screens: (1..=RIGHT_SIDEBAR_SCREEN_COUNT).collect(),
show_room_list_sidebar: true,
show_settings_on_connect: true,
favorite_room_ids: Vec::new(),
Expand All @@ -89,6 +98,8 @@ pub struct ProfileParams {
pub show_dashboard_header: bool,
pub show_dashboard_wire: bool,
pub show_right_sidebar: bool,
pub right_sidebar_mode: RightSidebarMode,
pub right_sidebar_screens: Vec<u8>,
pub show_room_list_sidebar: bool,
pub show_settings_on_connect: bool,
pub favorite_room_ids: Vec<Uuid>,
Expand All @@ -105,7 +116,8 @@ impl Profile {
/// Atomic partial update — merges
/// bio/country/timezone/theme_id/notify_kinds/notify_bell/notify_cooldown_mins/
/// enable_background_color/show_dashboard_header/show_dashboard_wire/
/// show_right_sidebar/show_room_list_sidebar/show_settings_on_connect into settings via
/// show_right_sidebar/right_sidebar_mode/right_sidebar_screens/
/// show_room_list_sidebar/show_settings_on_connect into settings via
/// `settings || jsonb_build_object(...)`, so concurrent writes to unrelated keys
/// (ignored_user_ids) are preserved.
pub async fn update(client: &Client, user_id: Uuid, params: ProfileParams) -> Result<Self> {
Expand All @@ -117,6 +129,9 @@ impl Profile {
.map(Uuid::to_string)
.collect::<Vec<_>>(),
)?;
let right_sidebar_screens_json = serde_json::to_value(normalize_right_sidebar_screens(
&params.right_sidebar_screens,
))?;
let cooldown = params.notify_cooldown_mins.max(0);
let bio = params.bio.trim().to_string();
let country = params
Expand Down Expand Up @@ -172,17 +187,19 @@ impl Profile {
'notify_format', $10::text,
'show_dashboard_header', $11::bool,
'show_right_sidebar', $12::bool,
'show_room_list_sidebar', $13::bool,
'show_settings_on_connect', $14::bool,
'favorite_room_ids', $15::jsonb,
'ide', $16::text,
'terminal', $17::text,
'os', $18::text,
'langs', $19::jsonb,
'show_dashboard_wire', $20::bool
'right_sidebar_mode', $13::text,
'right_sidebar_screens', $14::jsonb,
'show_room_list_sidebar', $15::bool,
'show_settings_on_connect', $16::bool,
'favorite_room_ids', $17::jsonb,
'ide', $18::text,
'terminal', $19::text,
'os', $20::text,
'langs', $21::jsonb,
'show_dashboard_wire', $22::bool
),
updated = current_timestamp
WHERE id = $21
WHERE id = $23
RETURNING *",
&[
&params.username,
Expand All @@ -197,6 +214,8 @@ impl Profile {
&notify_format,
&params.show_dashboard_header,
&params.show_right_sidebar,
&params.right_sidebar_mode.as_str(),
&right_sidebar_screens_json,
&params.show_room_list_sidebar,
&params.show_settings_on_connect,
&favorite_room_ids_json,
Expand Down Expand Up @@ -233,13 +252,25 @@ impl Profile {
show_dashboard_header: extract_show_dashboard_header(&user.settings),
show_dashboard_wire: extract_show_dashboard_wire(&user.settings),
show_right_sidebar: extract_show_right_sidebar(&user.settings),
right_sidebar_mode: extract_right_sidebar_mode(&user.settings),
right_sidebar_screens: extract_right_sidebar_screens(&user.settings),
show_room_list_sidebar: extract_show_room_list_sidebar(&user.settings),
show_settings_on_connect: extract_show_settings_on_connect(&user.settings),
favorite_room_ids: extract_favorite_room_ids(&user.settings),
}
}
}

fn normalize_right_sidebar_screens(screens: &[u8]) -> Vec<u8> {
let mut seen = BTreeSet::new();
for screen in screens {
if (1..=RIGHT_SIDEBAR_SCREEN_COUNT).contains(screen) {
seen.insert(*screen);
}
}
seen.into_iter().collect()
}

fn normalize_profile_text(value: Option<&str>) -> Option<String> {
value
.map(str::trim)
Expand Down
123 changes: 123 additions & 0 deletions late-core/src/models/user.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,37 @@ crate::model! {

pub const USERNAME_MAX_LEN: usize = 32;

/// Number of top-level screens (Dashboard, Arcade, Rooms, Artboard).
pub const RIGHT_SIDEBAR_SCREEN_COUNT: u8 = 4;

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum RightSidebarMode {
On,
Off,
Custom,
}

impl RightSidebarMode {
pub fn as_str(self) -> &'static str {
match self {
Self::On => "on",
Self::Off => "off",
Self::Custom => "custom",
}
}

pub fn cycle(self, forward: bool) -> Self {
match (self, forward) {
(Self::On, true) => Self::Off,
(Self::Off, true) => Self::Custom,
(Self::Custom, true) => Self::On,
(Self::On, false) => Self::Custom,
(Self::Off, false) => Self::On,
(Self::Custom, false) => Self::Off,
}
}
}

const IGNORED_USER_IDS_KEY: &str = "ignored_user_ids";
const THEME_ID_KEY: &str = "theme_id";
const AUDIO_SOURCE_KEY: &str = "audio_source";
Expand All @@ -60,6 +91,8 @@ const ENABLE_BACKGROUND_COLOR_KEY: &str = "enable_background_color";
const SHOW_DASHBOARD_HEADER_KEY: &str = "show_dashboard_header";
const SHOW_DASHBOARD_WIRE_KEY: &str = "show_dashboard_wire";
const SHOW_RIGHT_SIDEBAR_KEY: &str = "show_right_sidebar";
const RIGHT_SIDEBAR_MODE_KEY: &str = "right_sidebar_mode";
const RIGHT_SIDEBAR_SCREENS_KEY: &str = "right_sidebar_screens";
const SHOW_ROOM_LIST_SIDEBAR_KEY: &str = "show_room_list_sidebar";
const SHOW_SETTINGS_ON_CONNECT_KEY: &str = "show_settings_on_connect";
const FAVORITE_ROOM_IDS_KEY: &str = "favorite_room_ids";
Expand Down Expand Up @@ -510,12 +543,63 @@ pub fn extract_show_dashboard_wire(settings: &Value) -> bool {
}

pub fn extract_show_right_sidebar(settings: &Value) -> bool {
match settings
.get(RIGHT_SIDEBAR_MODE_KEY)
.and_then(Value::as_str)
.map(str::trim)
{
Some("on" | "custom") => return true,
Some("off") => return false,
_ => {}
}

settings
.get(SHOW_RIGHT_SIDEBAR_KEY)
.and_then(Value::as_bool)
.unwrap_or(true)
}

pub fn extract_right_sidebar_mode(settings: &Value) -> RightSidebarMode {
match settings
.get(RIGHT_SIDEBAR_MODE_KEY)
.and_then(Value::as_str)
.map(str::trim)
{
Some("on") => RightSidebarMode::On,
Some("off") => RightSidebarMode::Off,
Some("custom") => RightSidebarMode::Custom,
_ if settings
.get(SHOW_RIGHT_SIDEBAR_KEY)
.and_then(Value::as_bool)
.unwrap_or(true) =>
{
RightSidebarMode::On
}
_ => RightSidebarMode::Off,
}
}

pub fn extract_right_sidebar_screens(settings: &Value) -> Vec<u8> {
let Some(values) = settings
.get(RIGHT_SIDEBAR_SCREENS_KEY)
.and_then(Value::as_array)
else {
return (1..=RIGHT_SIDEBAR_SCREEN_COUNT).collect();
};

let mut screens = BTreeSet::new();
for value in values {
let Some(raw) = value.as_u64() else {
continue;
};
if (1..=u64::from(RIGHT_SIDEBAR_SCREEN_COUNT)).contains(&raw) {
screens.insert(raw as u8);
}
}

screens.into_iter().collect()
}

pub fn extract_show_room_list_sidebar(settings: &Value) -> bool {
settings
.get(SHOW_ROOM_LIST_SIDEBAR_KEY)
Expand Down Expand Up @@ -764,6 +848,45 @@ mod tests {
assert!(!extract_show_right_sidebar(&settings));
}

#[test]
fn extract_show_right_sidebar_prefers_new_mode() {
let settings = json!({
"show_right_sidebar": true,
"right_sidebar_mode": "off",
});
assert!(!extract_show_right_sidebar(&settings));
}

#[test]
fn extract_right_sidebar_mode_reads_custom() {
let settings = json!({ "right_sidebar_mode": "custom" });
assert_eq!(
extract_right_sidebar_mode(&settings),
RightSidebarMode::Custom
);
}

#[test]
fn extract_right_sidebar_mode_falls_back_to_legacy_bool() {
let settings = json!({ "show_right_sidebar": false });
assert_eq!(extract_right_sidebar_mode(&settings), RightSidebarMode::Off);
}

#[test]
fn extract_right_sidebar_screens_defaults_to_all_screens() {
let settings = json!({});
assert_eq!(
extract_right_sidebar_screens(&settings),
(1..=RIGHT_SIDEBAR_SCREEN_COUNT).collect::<Vec<_>>()
);
}

#[test]
fn extract_right_sidebar_screens_dedupes_and_drops_invalid_values() {
let settings = json!({ "right_sidebar_screens": [3, 1, 3, 9, "2"] });
assert_eq!(extract_right_sidebar_screens(&settings), vec![1, 3]);
}

#[test]
fn extract_show_room_list_sidebar_defaults_to_true() {
let settings = json!({});
Expand Down
3 changes: 2 additions & 1 deletion late-ssh/src/app/help_modal/data.rs
Original file line number Diff line number Diff line change
Expand Up @@ -640,7 +640,8 @@ fn settings_help_lines() -> Vec<String> {
" country via picker, with Unicode flag rendering".to_string(),
" timezone via picker".to_string(),
" IDE, terminal, OS, and languages for profile/late.fetch surfaces".to_string(),
" background color, right sidebar, room list, and lounge info visibility".to_string(),
" background color, room list, and lounge info visibility".to_string(),
" right sidebar mode (on/off/custom) with per-screen visibility".to_string(),
" private RSS/Atom subscriptions".to_string(),
"".to_string(),
"How to open it".to_string(),
Expand Down
7 changes: 6 additions & 1 deletion late-ssh/src/app/input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1526,7 +1526,12 @@ fn handle_notifications_hud_click(app: &mut App, mouse: MouseEvent) -> bool {
fn app_content_area(app: &App) -> Rect {
let area = Rect::new(0, 0, app.size.0, app.size.1);
let inner = Block::default().borders(Borders::ALL).inner(area);
if app.profile_state.profile().show_right_sidebar {
let profile = app.profile_state.profile();
if crate::app::render::resolve_right_sidebar_enabled(
profile.right_sidebar_mode,
&profile.right_sidebar_screens,
app.screen,
) {
Layout::horizontal([Constraint::Fill(1), Constraint::Length(24)]).split(inner)[0]
} else {
inner
Expand Down
2 changes: 2 additions & 0 deletions late-ssh/src/app/profile/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,8 @@ fn profile_params_from_profile(profile: &Profile) -> ProfileParams {
show_dashboard_header: profile.show_dashboard_header,
show_dashboard_wire: profile.show_dashboard_wire,
show_right_sidebar: profile.show_right_sidebar,
right_sidebar_mode: profile.right_sidebar_mode,
right_sidebar_screens: profile.right_sidebar_screens.clone(),
show_room_list_sidebar: profile.show_room_list_sidebar,
show_settings_on_connect: profile.show_settings_on_connect,
favorite_room_ids: profile.favorite_room_ids.clone(),
Expand Down
37 changes: 35 additions & 2 deletions late-ssh/src/app/render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ use ratatui::{
};

use late_core::models::leaderboard::LeaderboardData;
use late_core::models::user::RightSidebarMode;

use super::{
artboard,
Expand Down Expand Up @@ -70,6 +71,30 @@ fn sidebar_enabled(show_settings: bool, draft_enabled: bool, profile_enabled: bo
}
}

/// Map a top-level screen to its 1-based slot in `right_sidebar_screens`.
pub(crate) fn screen_number(screen: Screen) -> u8 {
match screen {
Screen::Dashboard => 1,
Screen::Arcade => 2,
Screen::Rooms => 3,
Screen::Artboard => 4,
}
}

/// Resolve whether the right sidebar should render on `screen` given a profile
/// (or draft) sidebar mode and per-screen visibility set.
pub(crate) fn resolve_right_sidebar_enabled(
mode: RightSidebarMode,
screens: &[u8],
screen: Screen,
) -> bool {
match mode {
RightSidebarMode::On => true,
RightSidebarMode::Off => false,
RightSidebarMode::Custom => screens.contains(&screen_number(screen)),
}
}

fn room_list_sidebar_enabled(
show_settings: bool,
draft_enabled: bool,
Expand Down Expand Up @@ -228,8 +253,16 @@ impl App {
let area = Rect::new(0, 0, self.size.0, self.size.1);
let show_right_sidebar = sidebar_enabled(
self.show_settings,
self.settings_modal_state.draft().show_right_sidebar,
self.profile_state.profile().show_right_sidebar,
resolve_right_sidebar_enabled(
self.settings_modal_state.draft().right_sidebar_mode,
&self.settings_modal_state.draft().right_sidebar_screens,
self.screen,
),
resolve_right_sidebar_enabled(
self.profile_state.profile().right_sidebar_mode,
&self.profile_state.profile().right_sidebar_screens,
self.screen,
),
);
let show_room_list_sidebar = room_list_sidebar_enabled(
self.show_settings,
Expand Down
Loading