From 8c25f93afe108a9a3ea49b8b025d99274240f433 Mon Sep 17 00:00:00 2001 From: KeelrLeek <156356974+KeelrLeek@users.noreply.github.com> Date: Wed, 2 Sep 2026 13:43:16 -0500 Subject: [PATCH] community: one area for the people on both networks, and the hub grows search tabs, a folder column and a queue column --- src-tauri/src/commands.rs | 3 + src-tauri/src/community.rs | 89 ++ src-tauri/src/lib.rs | 28 + src/App.svelte | 4 + src/components/GlassBar.svelte | 2 +- src/components/Icon.svelte | 16 + src/components/Identicon.svelte | 133 +++ src/components/Sidebar.svelte | 1 + src/lib/api.js | 24 + src/lib/community.svelte.js | 301 +++++++ src/lib/navigation.svelte.js | 1 + src/lib/state.svelte.js | 224 +++-- src/views/Community.svelte | 1467 +++++++++++++++++++++++++++++++ src/views/Soulseek.svelte | 143 ++- 14 files changed, 2368 insertions(+), 68 deletions(-) create mode 100644 src-tauri/src/community.rs create mode 100644 src/components/Identicon.svelte create mode 100644 src/lib/community.svelte.js create mode 100644 src/views/Community.svelte diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index f165310..7bd86ba 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -857,6 +857,9 @@ pub async fn connect_and_login( "id": u.id, "username": u.username, "name": u.filename.rsplit('\\').next().unwrap_or(&u.filename), + // Full shared path too — Transfers shows the + // parent folder ahead of the name, Qt-style. + "path": u.filename, "state": state, "sent": u.sent, "total": u.total, diff --git a/src-tauri/src/community.rs b/src-tauri/src/community.rs new file mode 100644 index 0000000..7123580 --- /dev/null +++ b/src-tauri/src/community.rs @@ -0,0 +1,89 @@ +//! The Community area's own small backend: the local contact metadata +//! (petnames, groups, notes, the two local marks) and the file dialogs the +//! You section needs. Everything Soulseek lives in commands.rs, everything +//! Freenet in freenet.rs / persona.rs / profile.rs — this is the glue that +//! is neither. + +use serde::Deserialize; + +use crate::config::{self, ContactMeta}; + +#[tauri::command] +pub fn contacts_meta_list(app: tauri::AppHandle) -> Vec { + config::load(&app).contacts +} + +/// Upsert one contact's local metadata. An entry with nothing set is +/// dropped rather than stored empty, so the list only ever holds people the +/// user actually said something about. +#[tauri::command] +pub fn contact_meta_set(app: tauri::AppHandle, meta: ContactMeta) -> Vec { + let blank = |s: &Option| s.as_deref().map_or(true, |v| v.trim().is_empty()); + let mut cfg = config::load(&app); + cfg.contacts.retain(|c| c.username != meta.username); + let empty = blank(&meta.petname) + && blank(&meta.group) + && blank(&meta.note) + && !meta.trusted + && !meta.favorite; + if !empty { + let trim = |s: Option| s.map(|v| v.trim().to_string()).filter(|v| !v.is_empty()); + cfg.contacts.push(ContactMeta { + username: meta.username, + petname: trim(meta.petname), + group: trim(meta.group), + note: trim(meta.note), + trusted: meta.trusted, + favorite: meta.favorite, + }); + } + config::save(&app, &cfg); + cfg.contacts +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum PickKind { + /// A source image for the avatar pipeline. + Image, + /// An existing recovery artifact to import. + RecoveryOpen, + /// Where to write a new recovery artifact. + RecoverySave, +} + +const RECOVERY_EXT: &str = "ssrecovery"; + +/// Native file dialogs for the You section. Paths only — the bytes behind +/// them are read and written by Rust (persona.rs, profile.rs), never by the +/// webview. +#[tauri::command] +pub async fn community_pick_file( + app: tauri::AppHandle, + kind: PickKind, +) -> Result, String> { + use tauri_plugin_dialog::DialogExt; + let (tx, rx) = tokio::sync::oneshot::channel(); + let done = move |p: Option| { + let _ = tx.send(p.map(|p| p.to_string())); + }; + match kind { + PickKind::Image => app + .dialog() + .file() + .add_filter("Image", &["jpg", "jpeg", "png", "webp"]) + .pick_file(done), + PickKind::RecoveryOpen => app + .dialog() + .file() + .add_filter("SoulStream recovery file", &[RECOVERY_EXT]) + .pick_file(done), + PickKind::RecoverySave => app + .dialog() + .file() + .add_filter("SoulStream recovery file", &[RECOVERY_EXT]) + .set_file_name(&format!("soulstream-identity.{RECOVERY_EXT}")) + .save_file(done), + } + Ok(rx.await.unwrap_or(None)) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3e26b20..66ed24c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,7 +1,10 @@ mod commands; +mod community; mod config; mod cover_logo; mod diagnostics; +mod freenet; +mod persona; mod hero_crop; mod import; mod hero_detector; @@ -11,6 +14,7 @@ mod lyric_prep; mod lyrics; mod pipeline; mod playback; +mod profile; mod playlist_formats; mod playlists; mod provider_playlist; @@ -57,6 +61,10 @@ pub fn run() { preview::init(app.handle()); loudness::init(app.handle()); diagnostics::init(app.handle()); + // The experimental Freenet layer: applies the saved opt-in (off + // by default) and forwards adapter status to the webview. Fully + // isolated — its absence or failure touches nothing else. + freenet::init(app.handle()); if let Err(e) = pipeline::init(app.handle()) { log::error!("library init failed: {e}"); } @@ -215,6 +223,26 @@ pub fn run() { commands::preload_cached_stream, commands::stream_waveform, commands::stream_cache_ready, + freenet::freenet_status, + freenet::freenet_set_enabled, + freenet::freenet_detect, + freenet::freenet_set_load_remote_images, + freenet::freenet_node_info, + freenet::freenet_node_start, + freenet::freenet_node_stop, + community::contacts_meta_list, + community::contact_meta_set, + community::community_pick_file, + persona::persona_status, + persona::persona_create, + persona::persona_export, + persona::persona_import, + persona::persona_rotate, + persona::persona_remove, + profile::profile_get, + profile::profile_publish, + profile::profile_set_avatar, + profile::profile_clear_avatar, preview::preview_resolve, preview::preview_prewarm, preview::preview_load, diff --git a/src/App.svelte b/src/App.svelte index f415891..6d5d376 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -13,6 +13,7 @@ import { previewUi } from "./lib/preview/previewSession.svelte.js"; import Home from "./views/Home.svelte"; import Soulseek from "./views/Soulseek.svelte"; + import Community from "./views/Community.svelte"; import Library from "./views/Library.svelte"; import Settings from "./views/Settings.svelte"; import Playlist from "./views/Playlist.svelte"; @@ -50,6 +51,7 @@ soulseek: Soulseek, search: Soulseek, downloads: Soulseek, + community: Community, library: Library, settings: Settings, playlist: Playlist, @@ -89,6 +91,8 @@ "soulseek", "search", "downloads", + // Community is the hub's sibling: same "start BELOW the bar" shape. + "community", // The Library follows the discography's exception: its blurred // ambience pins behind the glass and must begin at the very top of // the pane, so the page carries its own top inset instead of taking diff --git a/src/components/GlassBar.svelte b/src/components/GlassBar.svelte index 64ffa6e..6154b77 100644 --- a/src/components/GlassBar.svelte +++ b/src/components/GlassBar.svelte @@ -31,7 +31,7 @@ // The Soulseek hub carries Qt's connection status in the bar's right — // "online as X · sharing N files" — the way the Qt titlebar did. - const HUB_VIEWS = new Set(["soulseek", "search", "downloads"]); + const HUB_VIEWS = new Set(["soulseek", "search", "downloads", "community"]); const showConn = $derived(HUB_VIEWS.has(ui.view)); import { canGoBack, canGoForward, trail } from "../lib/navigation.svelte.js"; import { markAppScroll, appScrollIdleFor } from "../lib/scrollbar.js"; diff --git a/src/components/Icon.svelte b/src/components/Icon.svelte index dc51c8b..30dcfed 100644 --- a/src/components/Icon.svelte +++ b/src/components/Icon.svelte @@ -38,6 +38,22 @@ trash: '', expand: '', shrink: '', + // The Community area's glyphs (2026-09-02). + hash: '', + inbox: '', + compass: '', + sparkle: '', + message: '', + send: '', + copy: '', + key: '', + shield: '', + ban: '', + eye: '', + star: '', + bell: '', + more: '', + devices: '', "chevron-left": '', "chevron-right": '', "chevron-down": '', diff --git a/src/components/Identicon.svelte b/src/components/Identicon.svelte new file mode 100644 index 0000000..4528498 --- /dev/null +++ b/src/components/Identicon.svelte @@ -0,0 +1,133 @@ + + + + + + {#if photo} + + {:else} + {@html svg} + {/if} + {#if presence} + + {/if} + + + diff --git a/src/components/Sidebar.svelte b/src/components/Sidebar.svelte index 290cbf0..35296d6 100644 --- a/src/components/Sidebar.svelte +++ b/src/components/Sidebar.svelte @@ -191,6 +191,7 @@ const items = [ { id: "home", label: "Home", icon: "home" }, { id: "soulseek", label: "Soulseek", icon: "globe" }, + { id: "community", label: "Community", icon: "users" }, { id: "library", label: "Your Library", icon: "library" }, { id: "settings", label: "Settings", icon: "settings" }, { id: "playlists", label: "Playlists", icon: "playlist" }, diff --git a/src/lib/api.js b/src/lib/api.js index cd55c16..a3f5c2c 100644 --- a/src/lib/api.js +++ b/src/lib/api.js @@ -488,3 +488,27 @@ export function onEvent(name, handler) { const un = listen(name, (e) => handler(e.payload)).catch(() => () => {}); return () => un.then((f) => f()); } + +// ---- the Community area: Freenet layer, persona, profile, local contacts ---- +export const freenetStatus = () => invoke("freenet_status"); +export const freenetSetEnabled = (enabled) => invoke("freenet_set_enabled", { enabled }); +export const freenetDetect = () => invoke("freenet_detect"); +export const freenetSetLoadRemoteImages = (on) => invoke("freenet_set_load_remote_images", { on }); +export const freenetNodeInfo = () => invoke("freenet_node_info"); +export const freenetNodeStart = () => invoke("freenet_node_start"); +export const freenetNodeStop = () => invoke("freenet_node_stop"); +export const personaStatus = () => invoke("persona_status"); +export const personaCreate = () => invoke("persona_create"); +export const personaExport = (passphrase, path) => invoke("persona_export", { passphrase, path }); +export const personaImport = (passphrase, path, force = false) => + invoke("persona_import", { passphrase, path, force }); +export const personaRotate = () => invoke("persona_rotate"); +export const personaRemove = () => invoke("persona_remove"); +export const profileGet = (persona = null, force = false) => invoke("profile_get", { persona, force }); +export const profilePublish = (displayName, bio) => invoke("profile_publish", { displayName, bio }); +export const profileSetAvatar = (imagePath) => invoke("profile_set_avatar", { imagePath }); +export const profileClearAvatar = () => invoke("profile_clear_avatar"); +export const contactsMetaList = () => invoke("contacts_meta_list"); +export const contactMetaSet = (meta) => invoke("contact_meta_set", { meta }); +/** kind: "image" | "recovery-open" | "recovery-save" -> a path, or null. */ +export const communityPickFile = (kind) => invoke("community_pick_file", { kind }); diff --git a/src/lib/community.svelte.js b/src/lib/community.svelte.js new file mode 100644 index 0000000..32019b4 --- /dev/null +++ b/src/lib/community.svelte.js @@ -0,0 +1,301 @@ +// The Community area's own store: the Freenet layer's status, the persona, +// the published profile, the local contact metadata and the two device-local +// memories (last seen, favourite rooms). Everything Soulseek-live — chats, +// rooms, buddies — stays in state.svelte.js's `slsk` and is only READ here. +// +// Two words the labels below are careful about, from the design's truthful +// list (FREENET-SUPPLEMENT-DESIGN §10.3): a successful fetch is "synced as of +//