forked from Keelrleek/SoulStream
community: the drawer asks a peer for its card and the server for interests, and discover gets the similar-users shelf
This commit is contained in:
1 parent
64a7561750
commit
5160d97a5f
10 files changed
+412
-2
No files matched your search
@@ -277,6 +277,23 @@ fn handle_server_frame(inner: &Arc<Inner>, mut body: Bytes) {
|
|||||||
dir_count,
|
dir_count,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
server::ServerEvent::UserInterests { username, likes, hates } => {
|
||||||
|
let waiters = inner
|
||||||
|
.pending_interests
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.remove(&username)
|
||||||
|
.unwrap_or_default();
|
||||||
|
for tx in waiters {
|
||||||
|
let _ = tx.send((likes.clone(), hates.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
server::ServerEvent::SimilarUsers { users } => {
|
||||||
|
let waiters: Vec<_> = inner.pending_similar.lock().unwrap().drain(..).collect();
|
||||||
|
for tx in waiters {
|
||||||
|
let _ = tx.send(users.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
server::ServerEvent::CannotConnect { .. } | server::ServerEvent::Unhandled { .. } => {}
|
server::ServerEvent::CannotConnect { .. } | server::ServerEvent::Unhandled { .. } => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -608,6 +625,21 @@ fn route_peer_event(inner: &Arc<Inner>, username: &str, ev: peer::PeerEvent) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
UserInfoResponse { description, picture, total_uploads, queue_size, has_free_slot } => {
|
||||||
|
let info = crate::UserInfo { description, picture, total_uploads, queue_size, has_free_slot };
|
||||||
|
let waiters = inner
|
||||||
|
.pending_user_infos
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.remove(username)
|
||||||
|
.unwrap_or_default();
|
||||||
|
if waiters.is_empty() {
|
||||||
|
log::debug!("unsolicited user info from {username}");
|
||||||
|
}
|
||||||
|
for tx in waiters {
|
||||||
|
let _ = tx.send(info.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
UserInfoRequest => {
|
UserInfoRequest => {
|
||||||
if let Some(h) = inner.peers.lock().unwrap().get(username) {
|
if let Some(h) = inner.peers.lock().unwrap().get(username) {
|
||||||
crate::responder::respond_user_info(inner, h);
|
crate::responder::respond_user_info(inner, h);
|
||||||
|
|||||||
@@ -184,6 +184,17 @@ pub(crate) struct TransferAck {
|
|||||||
/// or the denial that arrived instead.
|
/// or the denial that arrived instead.
|
||||||
pub(crate) type UploadOffer = std::result::Result<(u32, u64), String>; // (remote token, size)
|
pub(crate) type UploadOffer = std::result::Result<(u32, u64), String>; // (remote token, size)
|
||||||
|
|
||||||
|
/// A peer's user-info card as they sent it. The picture is untouched bytes:
|
||||||
|
/// the app decides whether and how to decode it.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct UserInfo {
|
||||||
|
pub description: String,
|
||||||
|
pub picture: Option<Vec<u8>>,
|
||||||
|
pub total_uploads: u32,
|
||||||
|
pub queue_size: u32,
|
||||||
|
pub has_free_slot: bool,
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) struct Inner {
|
pub(crate) struct Inner {
|
||||||
pub server_tx: mpsc::UnboundedSender<Vec<u8>>,
|
pub server_tx: mpsc::UnboundedSender<Vec<u8>>,
|
||||||
pub event_tx: mpsc::UnboundedSender<Event>,
|
pub event_tx: mpsc::UnboundedSender<Event>,
|
||||||
@@ -206,6 +217,9 @@ pub(crate) struct Inner {
|
|||||||
pub peers: Mutex<HashMap<String, PeerHandle>>,
|
pub peers: Mutex<HashMap<String, PeerHandle>>,
|
||||||
/// Browse requests awaiting a peer's share listing, by username.
|
/// Browse requests awaiting a peer's share listing, by username.
|
||||||
pub pending_browses: Mutex<HashMap<String, Vec<oneshot::Sender<Vec<Directory>>>>>,
|
pub pending_browses: Mutex<HashMap<String, Vec<oneshot::Sender<Vec<Directory>>>>>,
|
||||||
|
pub pending_user_infos: Mutex<HashMap<String, Vec<oneshot::Sender<UserInfo>>>>,
|
||||||
|
pub pending_interests: Mutex<HashMap<String, Vec<oneshot::Sender<(Vec<String>, Vec<String>)>>>>,
|
||||||
|
pub pending_similar: Mutex<Vec<oneshot::Sender<Vec<(String, u32)>>>>,
|
||||||
/// TransferResponse waits: (username, our token).
|
/// TransferResponse waits: (username, our token).
|
||||||
pub transfer_waits: Mutex<HashMap<(String, u32), oneshot::Sender<TransferAck>>>,
|
pub transfer_waits: Mutex<HashMap<(String, u32), oneshot::Sender<TransferAck>>>,
|
||||||
/// TransferRequest(upload) waits: (username, remote filename).
|
/// TransferRequest(upload) waits: (username, remote filename).
|
||||||
@@ -317,6 +331,9 @@ impl Client {
|
|||||||
expected_transfers: Mutex::new(HashMap::new()),
|
expected_transfers: Mutex::new(HashMap::new()),
|
||||||
peers: Mutex::new(HashMap::new()),
|
peers: Mutex::new(HashMap::new()),
|
||||||
pending_browses: Mutex::new(HashMap::new()),
|
pending_browses: Mutex::new(HashMap::new()),
|
||||||
|
pending_user_infos: Mutex::new(HashMap::new()),
|
||||||
|
pending_interests: Mutex::new(HashMap::new()),
|
||||||
|
pending_similar: Mutex::new(Vec::new()),
|
||||||
transfer_waits: Mutex::new(HashMap::new()),
|
transfer_waits: Mutex::new(HashMap::new()),
|
||||||
upload_offers: Mutex::new(HashMap::new()),
|
upload_offers: Mutex::new(HashMap::new()),
|
||||||
downloads_by_file: Mutex::new(HashMap::new()),
|
downloads_by_file: Mutex::new(HashMap::new()),
|
||||||
@@ -549,6 +566,81 @@ impl Client {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A user's info card over a peer connection — spawn-shaped like
|
||||||
|
/// `browse`: drop whatever lock guarded the `Client` before awaiting.
|
||||||
|
pub fn user_info(
|
||||||
|
&self,
|
||||||
|
username: &str,
|
||||||
|
) -> impl std::future::Future<Output = Result<UserInfo>> + Send + 'static {
|
||||||
|
let inner = self.inner.clone();
|
||||||
|
let username = username.to_string();
|
||||||
|
async move {
|
||||||
|
let handle = conn::get_or_connect_peer(&inner, &username).await?;
|
||||||
|
let (tx, rx) = oneshot::channel();
|
||||||
|
inner
|
||||||
|
.pending_user_infos
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.entry(username.clone())
|
||||||
|
.or_default()
|
||||||
|
.push(tx);
|
||||||
|
handle
|
||||||
|
.write_tx
|
||||||
|
.send(peer::user_info_request())
|
||||||
|
.map_err(|_| ClientError::PeerUnreachable(username.clone()))?;
|
||||||
|
match tokio::time::timeout(std::time::Duration::from_secs(30), rx).await {
|
||||||
|
Ok(Ok(info)) => Ok(info),
|
||||||
|
_ => {
|
||||||
|
inner.pending_user_infos.lock().unwrap().remove(&username);
|
||||||
|
Err(ClientError::Timeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A user's declared likes and hates, from the server. Spawn-shaped.
|
||||||
|
pub fn user_interests(
|
||||||
|
&self,
|
||||||
|
username: &str,
|
||||||
|
) -> impl std::future::Future<Output = Result<(Vec<String>, Vec<String>)>> + Send + 'static {
|
||||||
|
let inner = self.inner.clone();
|
||||||
|
let username = username.to_string();
|
||||||
|
async move {
|
||||||
|
let (tx, rx) = oneshot::channel();
|
||||||
|
inner
|
||||||
|
.pending_interests
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.entry(username.clone())
|
||||||
|
.or_default()
|
||||||
|
.push(tx);
|
||||||
|
inner.send_server(server::user_interests(&username))?;
|
||||||
|
match tokio::time::timeout(std::time::Duration::from_secs(20), rx).await {
|
||||||
|
Ok(Ok(v)) => Ok(v),
|
||||||
|
_ => {
|
||||||
|
inner.pending_interests.lock().unwrap().remove(&username);
|
||||||
|
Err(ClientError::Timeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Users the server ranks as similar to us by shared interests. Spawn-shaped.
|
||||||
|
pub fn similar_users(
|
||||||
|
&self,
|
||||||
|
) -> impl std::future::Future<Output = Result<Vec<(String, u32)>>> + Send + 'static {
|
||||||
|
let inner = self.inner.clone();
|
||||||
|
async move {
|
||||||
|
let (tx, rx) = oneshot::channel();
|
||||||
|
inner.pending_similar.lock().unwrap().push(tx);
|
||||||
|
inner.send_server(server::similar_users())?;
|
||||||
|
match tokio::time::timeout(std::time::Duration::from_secs(20), rx).await {
|
||||||
|
Ok(Ok(v)) => Ok(v),
|
||||||
|
_ => Err(ClientError::Timeout),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn listen_port(&self) -> u16 {
|
pub fn listen_port(&self) -> u16 {
|
||||||
self.inner.listen_port
|
self.inner.listen_port
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -125,6 +125,11 @@ pub fn browse_request() -> Vec<u8> {
|
|||||||
Writer::new().finish_u32(code::BROWSE_REQUEST)
|
Writer::new().finish_u32(code::BROWSE_REQUEST)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ask a peer for their user-info card (code 15; answered with code 16).
|
||||||
|
pub fn user_info_request() -> Vec<u8> {
|
||||||
|
Writer::new().finish_u32(code::USER_INFO_REQUEST)
|
||||||
|
}
|
||||||
|
|
||||||
/// Legacy enqueue message; some clients expect it before TransferRequest.
|
/// Legacy enqueue message; some clients expect it before TransferRequest.
|
||||||
pub fn queue_download(filename: &str) -> Vec<u8> {
|
pub fn queue_download(filename: &str) -> Vec<u8> {
|
||||||
Writer::new()
|
Writer::new()
|
||||||
@@ -247,6 +252,15 @@ pub enum PeerEvent {
|
|||||||
UploadFailed {
|
UploadFailed {
|
||||||
filename: String,
|
filename: String,
|
||||||
},
|
},
|
||||||
|
/// A peer's user-info card, the answer to our user_info_request.
|
||||||
|
UserInfoResponse {
|
||||||
|
description: String,
|
||||||
|
/// Raw bytes as sent — hostile input until decoded under limits.
|
||||||
|
picture: Option<Vec<u8>>,
|
||||||
|
total_uploads: u32,
|
||||||
|
queue_size: u32,
|
||||||
|
has_free_slot: bool,
|
||||||
|
},
|
||||||
// Requests other peers make of us (M5 responder):
|
// Requests other peers make of us (M5 responder):
|
||||||
BrowseRequest,
|
BrowseRequest,
|
||||||
UserInfoRequest,
|
UserInfoRequest,
|
||||||
@@ -320,6 +334,17 @@ pub fn decode(code: u32, payload: Bytes) -> Result<PeerEvent> {
|
|||||||
code::UPLOAD_FAILED => PeerEvent::UploadFailed {
|
code::UPLOAD_FAILED => PeerEvent::UploadFailed {
|
||||||
filename: r.string()?,
|
filename: r.string()?,
|
||||||
},
|
},
|
||||||
|
code::USER_INFO_RESPONSE => {
|
||||||
|
let description = r.string()?;
|
||||||
|
let picture = if r.bool()? { Some(r.bytes()?.to_vec()) } else { None };
|
||||||
|
PeerEvent::UserInfoResponse {
|
||||||
|
description,
|
||||||
|
picture,
|
||||||
|
total_uploads: r.u32()?,
|
||||||
|
queue_size: r.u32()?,
|
||||||
|
has_free_slot: r.bool().unwrap_or(false),
|
||||||
|
}
|
||||||
|
}
|
||||||
code::BROWSE_REQUEST => PeerEvent::BrowseRequest,
|
code::BROWSE_REQUEST => PeerEvent::BrowseRequest,
|
||||||
code::USER_INFO_REQUEST => PeerEvent::UserInfoRequest,
|
code::USER_INFO_REQUEST => PeerEvent::UserInfoRequest,
|
||||||
code::FOLDER_CONTENTS_REQUEST => PeerEvent::FolderContentsRequest {
|
code::FOLDER_CONTENTS_REQUEST => PeerEvent::FolderContentsRequest {
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ pub mod code {
|
|||||||
pub const PING: u32 = 32;
|
pub const PING: u32 = 32;
|
||||||
pub const SHARED_FOLDERS_AND_FILES: u32 = 35;
|
pub const SHARED_FOLDERS_AND_FILES: u32 = 35;
|
||||||
pub const GET_USER_STATS: u32 = 36;
|
pub const GET_USER_STATS: u32 = 36;
|
||||||
|
pub const USER_INTERESTS: u32 = 57;
|
||||||
|
pub const SIMILAR_USERS: u32 = 110;
|
||||||
pub const ROOM_LIST: u32 = 64;
|
pub const ROOM_LIST: u32 = 64;
|
||||||
pub const HAVE_NO_PARENTS: u32 = 71;
|
pub const HAVE_NO_PARENTS: u32 = 71;
|
||||||
pub const PARENT_MIN_SPEED: u32 = 83;
|
pub const PARENT_MIN_SPEED: u32 = 83;
|
||||||
@@ -136,6 +138,27 @@ pub fn get_user_stats(username: &str) -> Vec<u8> {
|
|||||||
.finish_u32(code::GET_USER_STATS)
|
.finish_u32(code::GET_USER_STATS)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A user's declared likes and hates (code 57).
|
||||||
|
pub fn user_interests(username: &str) -> Vec<u8> {
|
||||||
|
Writer::new()
|
||||||
|
.string(username)
|
||||||
|
.finish_u32(code::USER_INTERESTS)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Users the server ranks as similar to us by shared interests (code 110).
|
||||||
|
pub fn similar_users() -> Vec<u8> {
|
||||||
|
Writer::new().finish_u32(code::SIMILAR_USERS)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn string_list(r: &mut Reader) -> Result<Vec<String>> {
|
||||||
|
let n = r.u32()?.min(1024) as usize;
|
||||||
|
let mut v = Vec::with_capacity(n.min(64));
|
||||||
|
for _ in 0..n {
|
||||||
|
v.push(r.string()?);
|
||||||
|
}
|
||||||
|
Ok(v)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn say_chatroom(room: &str, message: &str) -> Vec<u8> {
|
pub fn say_chatroom(room: &str, message: &str) -> Vec<u8> {
|
||||||
Writer::new()
|
Writer::new()
|
||||||
.string(room)
|
.string(room)
|
||||||
@@ -226,6 +249,16 @@ pub enum ServerEvent {
|
|||||||
Embedded(Bytes),
|
Embedded(Bytes),
|
||||||
/// Public rooms and their user counts (pushed after login, and on
|
/// Public rooms and their user counts (pushed after login, and on
|
||||||
/// request). Private-room sections in the payload are ignored.
|
/// request). Private-room sections in the payload are ignored.
|
||||||
|
/// Answer to user_interests: what they declared, verbatim.
|
||||||
|
UserInterests {
|
||||||
|
username: String,
|
||||||
|
likes: Vec<String>,
|
||||||
|
hates: Vec<String>,
|
||||||
|
},
|
||||||
|
/// Answer to similar_users: (username, rating) as the server ranks them.
|
||||||
|
SimilarUsers {
|
||||||
|
users: Vec<(String, u32)>,
|
||||||
|
},
|
||||||
RoomList {
|
RoomList {
|
||||||
rooms: Vec<(String, u32)>,
|
rooms: Vec<(String, u32)>,
|
||||||
},
|
},
|
||||||
@@ -473,6 +506,20 @@ pub fn decode(code: u32, payload: Bytes) -> Result<ServerEvent> {
|
|||||||
dir_count: r.u32()?,
|
dir_count: r.u32()?,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
code::USER_INTERESTS => {
|
||||||
|
let username = r.string()?;
|
||||||
|
let likes = string_list(&mut r)?;
|
||||||
|
let hates = string_list(&mut r)?;
|
||||||
|
ServerEvent::UserInterests { username, likes, hates }
|
||||||
|
}
|
||||||
|
code::SIMILAR_USERS => {
|
||||||
|
let n = r.u32()?.min(4096) as usize;
|
||||||
|
let mut users = Vec::with_capacity(n.min(256));
|
||||||
|
for _ in 0..n {
|
||||||
|
users.push((r.string()?, r.u32()?));
|
||||||
|
}
|
||||||
|
ServerEvent::SimilarUsers { users }
|
||||||
|
}
|
||||||
code::ROOM_LIST => {
|
code::ROOM_LIST => {
|
||||||
// Public names + counts; the private-room sections that follow
|
// Public names + counts; the private-room sections that follow
|
||||||
// are deliberately not parsed.
|
// are deliberately not parsed.
|
||||||
|
|||||||
@@ -160,6 +160,16 @@ impl Reader {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A length-prefixed raw byte string (a user picture, for one): the same
|
||||||
|
/// framing as `string`, without the UTF-8 interpretation.
|
||||||
|
pub fn bytes(&mut self) -> Result<Bytes> {
|
||||||
|
let len = self.u32()? as usize;
|
||||||
|
if self.buf.remaining() < len {
|
||||||
|
return Err(ProtoError::UnexpectedEof);
|
||||||
|
}
|
||||||
|
Ok(self.buf.copy_to_bytes(len))
|
||||||
|
}
|
||||||
|
|
||||||
/// Replace the remaining payload with its zlib-decompressed form.
|
/// Replace the remaining payload with its zlib-decompressed form.
|
||||||
pub fn decompress(&mut self) -> Result<()> {
|
pub fn decompress(&mut self) -> Result<()> {
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
|
|||||||
@@ -1084,6 +1084,94 @@ pub struct BrowseDirDto {
|
|||||||
|
|
||||||
/// Walk a user's shares. Long-running: the client lock is held only long
|
/// Walk a user's shares. Long-running: the client lock is held only long
|
||||||
/// enough to start the request.
|
/// enough to start the request.
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct UserInfoDto {
|
||||||
|
pub username: String,
|
||||||
|
pub description: String,
|
||||||
|
/// The picture ONLY after the avatar pipeline's bounded decode + resize
|
||||||
|
/// (profile.rs): a peer's bytes are hostile input, never rendered raw.
|
||||||
|
pub picture_png_base64: Option<String>,
|
||||||
|
pub picture_note: Option<String>,
|
||||||
|
pub total_uploads: u32,
|
||||||
|
pub queue_size: u32,
|
||||||
|
pub has_free_slot: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A peer's native user-info card: description, picture, upload state.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn slsk_user_info(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
username: String,
|
||||||
|
) -> Result<UserInfoDto, String> {
|
||||||
|
let fut = {
|
||||||
|
let guard = state.client.lock().await;
|
||||||
|
let client = guard.as_ref().ok_or("not connected to Soulseek")?;
|
||||||
|
client.user_info(&username)
|
||||||
|
};
|
||||||
|
let info = fut.await.map_err(|e| e.to_string())?;
|
||||||
|
let (picture_png_base64, picture_note) = match info.picture.as_deref() {
|
||||||
|
None => (None, None),
|
||||||
|
Some(bytes) => match crate::profile::normalize_avatar(bytes) {
|
||||||
|
Ok(png) => {
|
||||||
|
use base64::Engine;
|
||||||
|
(Some(base64::engine::general_purpose::STANDARD.encode(png)), None)
|
||||||
|
}
|
||||||
|
Err(e) => (None, Some(format!("picture refused: {e}"))),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
Ok(UserInfoDto {
|
||||||
|
username,
|
||||||
|
description: info.description,
|
||||||
|
picture_png_base64,
|
||||||
|
picture_note,
|
||||||
|
total_uploads: info.total_uploads,
|
||||||
|
queue_size: info.queue_size,
|
||||||
|
has_free_slot: info.has_free_slot,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct InterestsDto {
|
||||||
|
pub likes: Vec<String>,
|
||||||
|
pub hates: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A user's declared Soulseek interests — raw likes and hates, not follows.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn slsk_user_interests(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
username: String,
|
||||||
|
) -> Result<InterestsDto, String> {
|
||||||
|
let fut = {
|
||||||
|
let guard = state.client.lock().await;
|
||||||
|
let client = guard.as_ref().ok_or("not connected to Soulseek")?;
|
||||||
|
client.user_interests(&username)
|
||||||
|
};
|
||||||
|
let (likes, hates) = fut.await.map_err(|e| e.to_string())?;
|
||||||
|
Ok(InterestsDto { likes, hates })
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct SimilarUserDto {
|
||||||
|
pub username: String,
|
||||||
|
pub rating: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Users the server ranks as similar to us by shared interests.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn slsk_similar_users(state: State<'_, AppState>) -> Result<Vec<SimilarUserDto>, String> {
|
||||||
|
let fut = {
|
||||||
|
let guard = state.client.lock().await;
|
||||||
|
let client = guard.as_ref().ok_or("not connected to Soulseek")?;
|
||||||
|
client.similar_users()
|
||||||
|
};
|
||||||
|
let users = fut.await.map_err(|e| e.to_string())?;
|
||||||
|
Ok(users
|
||||||
|
.into_iter()
|
||||||
|
.map(|(username, rating)| SimilarUserDto { username, rating })
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn slsk_browse_user(
|
pub async fn slsk_browse_user(
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
|
|||||||
@@ -120,6 +120,9 @@ pub fn run() {
|
|||||||
commands::slsk_message_user,
|
commands::slsk_message_user,
|
||||||
commands::slsk_room_list,
|
commands::slsk_room_list,
|
||||||
commands::slsk_browse_user,
|
commands::slsk_browse_user,
|
||||||
|
commands::slsk_user_info,
|
||||||
|
commands::slsk_user_interests,
|
||||||
|
commands::slsk_similar_users,
|
||||||
commands::buddy_list,
|
commands::buddy_list,
|
||||||
commands::buddy_add,
|
commands::buddy_add,
|
||||||
commands::buddy_remove,
|
commands::buddy_remove,
|
||||||
|
|||||||
@@ -148,6 +148,9 @@ export const slskMessageUser = (username, message) =>
|
|||||||
invoke("slsk_message_user", { username, message });
|
invoke("slsk_message_user", { username, message });
|
||||||
export const slskRoomList = () => invoke("slsk_room_list");
|
export const slskRoomList = () => invoke("slsk_room_list");
|
||||||
export const slskBrowseUser = (username) => invoke("slsk_browse_user", { username });
|
export const slskBrowseUser = (username) => invoke("slsk_browse_user", { username });
|
||||||
|
export const slskUserInfo = (username) => invoke("slsk_user_info", { username });
|
||||||
|
export const slskUserInterests = (username) => invoke("slsk_user_interests", { username });
|
||||||
|
export const slskSimilarUsers = () => invoke("slsk_similar_users");
|
||||||
export const buddyList = () => invoke("buddy_list");
|
export const buddyList = () => invoke("buddy_list");
|
||||||
export const buddyAdd = (username) => invoke("buddy_add", { username });
|
export const buddyAdd = (username) => invoke("buddy_add", { username });
|
||||||
export const buddyRemove = (username) => invoke("buddy_remove", { username });
|
export const buddyRemove = (username) => invoke("buddy_remove", { username });
|
||||||
|
|||||||
@@ -52,6 +52,13 @@ export const community = $state({
|
|||||||
roomFavs: readJson(LS_FAVS, []),
|
roomFavs: readJson(LS_FAVS, []),
|
||||||
/** Which person the drawer shows, or null. */
|
/** Which person the drawer shows, or null. */
|
||||||
drawer: null,
|
drawer: null,
|
||||||
|
/** username → native Soulseek card ({...UserInfoDto} | {error}) for
|
||||||
|
* the session; fetched only when a profile is surfaced (the drawer). */
|
||||||
|
userInfo: {},
|
||||||
|
/** username → {likes, hates} | {error}, same lifetime. */
|
||||||
|
interests: {},
|
||||||
|
/** The server's similar-users answer, or null before it was asked. */
|
||||||
|
similar: null,
|
||||||
/** key → true while an action runs (buttons disable on it). */
|
/** key → true while an action runs (buttons disable on it). */
|
||||||
busy: {},
|
busy: {},
|
||||||
/** The last action's error, shown where it happened. */
|
/** The last action's error, shown where it happened. */
|
||||||
@@ -299,3 +306,37 @@ export async function initCommunity() {
|
|||||||
await Promise.all([refreshFreenet(), loadContacts()]);
|
await Promise.all([refreshFreenet(), loadContacts()]);
|
||||||
await refreshPersona();
|
await refreshPersona();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- native Soulseek cards ---------------------------------------------------------
|
||||||
|
/** Fetch a person's native card and interests once per session, both in
|
||||||
|
* flight together; errors are kept per person so the drawer can say so. */
|
||||||
|
export async function loadUserInfo(username) {
|
||||||
|
if (!username) return;
|
||||||
|
if (!community.userInfo[username]) {
|
||||||
|
community.userInfo[username] = { pending: true };
|
||||||
|
api.slskUserInfo(username)
|
||||||
|
.then((v) => (community.userInfo[username] = v))
|
||||||
|
.catch((e) => (community.userInfo[username] = { error: String(e) }));
|
||||||
|
}
|
||||||
|
if (!community.interests[username]) {
|
||||||
|
community.interests[username] = { pending: true };
|
||||||
|
api.slskUserInterests(username)
|
||||||
|
.then((v) => (community.interests[username] = v))
|
||||||
|
.catch((e) => (community.interests[username] = { error: String(e) }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export async function loadSimilar(force = false) {
|
||||||
|
if (community.similar && !force) return;
|
||||||
|
community.busy.similar = true;
|
||||||
|
try {
|
||||||
|
community.similar = { users: await api.slskSimilarUsers() };
|
||||||
|
} catch (e) {
|
||||||
|
community.similar = { users: [], error: String(e) };
|
||||||
|
} finally {
|
||||||
|
delete community.busy.similar;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dev handle beside `__soulstream` (state.svelte.js): probes reach the store
|
||||||
|
// through this, never through a bare import (which would be a second copy).
|
||||||
|
if (import.meta.env.DEV) globalThis.__community = community;
|
||||||
@@ -56,6 +56,8 @@
|
|||||||
setContactMeta,
|
setContactMeta,
|
||||||
toggleRoomFav,
|
toggleRoomFav,
|
||||||
personaKey,
|
personaKey,
|
||||||
|
loadUserInfo,
|
||||||
|
loadSimilar,
|
||||||
} from "../lib/community.svelte.js";
|
} from "../lib/community.svelte.js";
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
@@ -385,6 +387,21 @@
|
|||||||
noteDraft = m?.note ?? "";
|
noteDraft = m?.note ?? "";
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
// The native card is fetched only for a surfaced profile (the design's
|
||||||
|
// rule), once per session per person, and only while signed in.
|
||||||
|
$effect(() => {
|
||||||
|
const u = drawerUser;
|
||||||
|
if (u && online) untrack(() => loadUserInfo(u));
|
||||||
|
});
|
||||||
|
const drawerInfo = $derived(drawerUser ? community.userInfo[drawerUser] : null);
|
||||||
|
const drawerInterests = $derived(drawerUser ? community.interests[drawerUser] : null);
|
||||||
|
const drawerPhoto = $derived(
|
||||||
|
drawerInfo?.picture_png_base64 ? `data:image/png;base64,${drawerInfo.picture_png_base64}` : null,
|
||||||
|
);
|
||||||
|
// Discover's similar-users shelf asks the server once per session.
|
||||||
|
$effect(() => {
|
||||||
|
if (community.section === "discover" && online) untrack(() => loadSimilar());
|
||||||
|
});
|
||||||
function saveMeta(patch = {}) {
|
function saveMeta(patch = {}) {
|
||||||
if (!drawerUser) return;
|
if (!drawerUser) return;
|
||||||
void setContactMeta({
|
void setContactMeta({
|
||||||
@@ -835,6 +852,27 @@
|
|||||||
<div class="emptyshelf"><Icon name="download" size={22} /><div>Nobody yet. People who send you files, or take files from you, appear here for the session.</div></div>
|
<div class="emptyshelf"><Icon name="download" size={22} /><div>Nobody yet. People who send you files, or take files from you, appear here for the session.</div></div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="shelf">
|
||||||
|
<div class="ax-head">
|
||||||
|
<h2 class="ax-title">Similar Soulseek interests</h2>
|
||||||
|
<span class="prov"><Icon name="heart" size={12} /> the server's match on declared likes · not follows</span>
|
||||||
|
</div>
|
||||||
|
{#if community.similar?.users?.length}
|
||||||
|
<div class="track">
|
||||||
|
{#each community.similar.users.slice(0, 24) as su (su.username)}
|
||||||
|
<button class="pcard" onclick={() => (community.drawer = su.username)}>
|
||||||
|
<span class="face" style:background="hsl({hueOf(su.username)} 30% 14%)"><Identicon seed={su.username} size={84} presence={presenceOf(su.username)} /></span>
|
||||||
|
<span class="nm">{su.username}</span>
|
||||||
|
<span class="why">shares <b>{su.rating}</b> of your declared likes{#if isContact(su.username)} · a contact{/if}</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else if community.busy.similar}
|
||||||
|
<div class="emptyshelf"><span class="bl"></span><div>Asking the server…</div></div>
|
||||||
|
{:else}
|
||||||
|
<div class="emptyshelf"><Icon name="heart" size={22} /><div>{community.similar?.error ? "The server did not answer." : online ? "The server matches people on the likes you declare on Soulseek — SoulStream has none set for you yet, so this shelf is empty." : "Sign in to Soulseek to ask the server."}</div>{#if online}<button class="ax-ghost sm" onclick={() => loadSimilar(true)} disabled={!!community.busy.similar}>Ask again</button>{/if}</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
<div class="shelf">
|
<div class="shelf">
|
||||||
<div class="ax-head">
|
<div class="ax-head">
|
||||||
<h2 class="ax-title">Library overlap</h2>
|
<h2 class="ax-title">Library overlap</h2>
|
||||||
@@ -1087,7 +1125,7 @@
|
|||||||
<div class="scrim" role="presentation" onclick={closeDrawer}></div>
|
<div class="scrim" role="presentation" onclick={closeDrawer}></div>
|
||||||
<aside class="drawer" aria-label="Profile of {drawerUser}">
|
<aside class="drawer" aria-label="Profile of {drawerUser}">
|
||||||
<div class="dhead">
|
<div class="dhead">
|
||||||
<Identicon seed={drawerUser} size={56} presence={presenceOf(drawerUser)} />
|
<Identicon seed={drawerUser} size={56} photo={drawerPhoto} presence={presenceOf(drawerUser)} />
|
||||||
<div class="dtx">
|
<div class="dtx">
|
||||||
<div class="nm">
|
<div class="nm">
|
||||||
<input class="pet" placeholder={drawerUser} bind:value={petDraft} onchange={() => saveMeta()} title="Your private petname for this person" />
|
<input class="pet" placeholder={drawerUser} bind:value={petDraft} onchange={() => saveMeta()} title="Your private petname for this person" />
|
||||||
@@ -1132,8 +1170,35 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<dt>Shares</dt><dd class="muted">not watched — add to contacts and Soulseek reports status and share counts</dd>
|
<dt>Shares</dt><dd class="muted">not watched — add to contacts and Soulseek reports status and share counts</dd>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if drawerInfo && !drawerInfo.pending && !drawerInfo.error}
|
||||||
|
<dt>Uploads</dt><dd>{drawerInfo.has_free_slot ? "a slot is free" : "no free slot"} · queue {fmtCount(drawerInfo.queue_size)} · {fmtCount(drawerInfo.total_uploads)} served</dd>
|
||||||
|
{/if}
|
||||||
</dl>
|
</dl>
|
||||||
<p class="hint">Description, country and interests come with the native user-info fetch — the next Soulseek piece for this drawer.</p>
|
{#if !online}
|
||||||
|
<p class="hint">Sign in to Soulseek to ask this person's client for their card.</p>
|
||||||
|
{:else if !drawerInfo || drawerInfo.pending}
|
||||||
|
<p class="hint"><span class="bl"></span> Asking their client for the card — direct, peer to peer.</p>
|
||||||
|
{:else if drawerInfo.error}
|
||||||
|
<p class="hint">Their client did not answer ({drawerInfo.error.replace(/^.*?: /, "")}) — offline, firewalled, or not sharing a card.</p>
|
||||||
|
{:else}
|
||||||
|
<p class="desc">{drawerInfo.description.trim() || "No description."}</p>
|
||||||
|
{#if drawerInfo.picture_note}<p class="hint warn">{drawerInfo.picture_note}</p>{/if}
|
||||||
|
{/if}
|
||||||
|
<div class="hint" style="margin-top:10px">Soulseek interests <span class="muted">— raw likes and hates, not follows</span></div>
|
||||||
|
{#if drawerInterests && !drawerInterests.pending && !drawerInterests.error}
|
||||||
|
{#if drawerInterests.likes.length || drawerInterests.hates.length}
|
||||||
|
<div class="tagrow">
|
||||||
|
{#each drawerInterests.likes as l (l)}<span class="tag">{l}</span>{/each}
|
||||||
|
{#each drawerInterests.hates as h (h)}<span class="tag hate">{h}</span>{/each}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="hint">None declared.</p>
|
||||||
|
{/if}
|
||||||
|
{:else if drawerInterests?.error}
|
||||||
|
<p class="hint">The server did not answer.</p>
|
||||||
|
{:else if online}
|
||||||
|
<p class="hint"><span class="bl"></span> asking the server</p>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="dsec">
|
<div class="dsec">
|
||||||
<h4>Signed SoulStream profile <span class="chip off">none presented</span></h4>
|
<h4>Signed SoulStream profile <span class="chip off">none presented</span></h4>
|
||||||
@@ -1488,6 +1553,10 @@
|
|||||||
.grp-in:focus { border-color: rgba(255, 255, 255, 0.28); }
|
.grp-in:focus { border-color: rgba(255, 255, 255, 0.28); }
|
||||||
.note-in { width: 100%; margin-top: 8px; background: var(--surf-hi); border: 1px solid transparent; border-radius: 8px; color: #fff; padding: 8px 12px; font: inherit; font-size: 12.5px; outline: none; resize: none; }
|
.note-in { width: 100%; margin-top: 8px; background: var(--surf-hi); border: 1px solid transparent; border-radius: 8px; color: #fff; padding: 8px 12px; font: inherit; font-size: 12.5px; outline: none; resize: none; }
|
||||||
.note-in:focus { border-color: rgba(255, 255, 255, 0.28); }
|
.note-in:focus { border-color: rgba(255, 255, 255, 0.28); }
|
||||||
|
.tagrow { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 6px; }
|
||||||
|
.tag { font-size: 11px; font-weight: 700; padding: 3px 9px; border-radius: 99px; background: var(--surf-hi); color: var(--text-secondary); }
|
||||||
|
.tag.hate { text-decoration: line-through; opacity: 0.7; }
|
||||||
|
.desc { margin: 8px 0 0; font-size: 12.5px; line-height: 1.5; color: var(--text-secondary); white-space: pre-wrap; overflow-wrap: anywhere; max-height: 180px; overflow-y: auto; }
|
||||||
.dsec { margin-top: 18px; padding-top: 14px; border-top: 1px solid var(--hair); }
|
.dsec { margin-top: 18px; padding-top: 14px; border-top: 1px solid var(--hair); }
|
||||||
.dsec h4 { margin: 0 0 8px; font-size: 10.5px; font-weight: 900; letter-spacing: 0.1em; text-transform: uppercase; color: var(--text-muted); display: flex; align-items: center; gap: 8px; }
|
.dsec h4 { margin: 0 0 8px; font-size: 10.5px; font-weight: 900; letter-spacing: 0.1em; text-transform: uppercase; color: var(--text-muted); display: flex; align-items: center; gap: 8px; }
|
||||||
.dsec h4 .src, .dsec h4 .chip { margin-left: auto; }
|
.dsec h4 .src, .dsec h4 .chip { margin-left: auto; }
|
||||||
|
|||||||
Reference in new issue
Block a user