Compare commits

...

5 Commits

Author SHA1 Message Date
cloudwithax 044b5ca111 feat(storage): upsert directory entry to preserve existing data on conflict 2026-02-19 17:48:05 -05:00
cloudwithax ef6114a751 feat(frontend): relay discoverability toggle, sign-up disclosure, offline badge
- setRelayDiscoverable IPC wrapper in tauri.ts
- relay_discoverable in defaultSettings + toggleRelayDiscoverable function
- Privacy settings: discoverable on relay toggle
- Sign-up: disclosure text about relay username storage
- User directory: WifiOff badge for relay-only (offline) peers
- Improved empty state hint text

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
2026-02-19 12:42:09 -05:00
cloudwithax 72a82c1a11 feat(commands): relay-fallback search + set_relay_discoverable command
search_directory falls back to relay when local results < 5.
Relay stubs upserted with INSERT OR IGNORE to preserve existing data.
set_relay_discoverable persists setting and notifies running node.

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
2026-02-19 12:35:48 -05:00
cloudwithax cb45e2e463 feat(node): wire directory service into event loop
- Registers profile on relay reservation accepted (if discoverable)
- DirectorySearch, DirectoryRegister, DirectoryRemove, SetRelayDiscoverable commands
- Pending reply map for async search results

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
2026-02-19 12:25:15 -05:00
cloudwithax c01dc94338 feat(app): directory protocol types, behaviour, relay_discoverable setting
- protocol/directory.rs: DirectoryRequest/Response/ProfileEntry + DIRECTORY_PROTOCOL
- node/behaviour.rs: directory_service field on DuskBehaviour
- node/swarm.rs: outbound directory_service cbor behaviour
- storage/disk.rs: relay_discoverable bool in UserSettings (default true)
- types.ts: relay_discoverable added to UserSettings interface

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
2026-02-19 12:19:43 -05:00
14 changed files with 333 additions and 4 deletions

View File

@ -254,12 +254,15 @@ pub async fn search_directory(
query: String, query: String,
) -> Result<Vec<DirectoryEntry>, String> { ) -> Result<Vec<DirectoryEntry>, String> {
ipc_log!("search_directory", { ipc_log!("search_directory", {
let query_trimmed = query.trim().to_string();
// local search first
let entries = state let entries = state
.storage .storage
.load_directory() .load_directory()
.map_err(|e| format!("failed to load directory: {}", e))?; .map_err(|e| format!("failed to load directory: {}", e))?;
let query_lower = query.to_lowercase(); let query_lower = query_trimmed.to_lowercase();
let mut results: Vec<DirectoryEntry> = entries let mut results: Vec<DirectoryEntry> = entries
.into_values() .into_values()
.filter(|entry| { .filter(|entry| {
@ -267,8 +270,63 @@ pub async fn search_directory(
|| entry.peer_id.to_lowercase().contains(&query_lower) || entry.peer_id.to_lowercase().contains(&query_lower)
}) })
.collect(); .collect();
results.sort_by(|a, b| b.last_seen.cmp(&a.last_seen)); results.sort_by(|a, b| b.last_seen.cmp(&a.last_seen));
// relay fallback when local results are sparse
if results.len() < 5 && !query_trimmed.is_empty() {
let node_handle = state.node_handle.lock().await;
if let Some(ref handle) = *node_handle {
let (tx, rx) = tokio::sync::oneshot::channel();
let _ = handle
.command_tx
.send(crate::node::NodeCommand::DirectorySearch {
query: query_trimmed.clone(),
reply: tx,
})
.await;
drop(node_handle);
// wait up to 5 seconds for relay response
if let Ok(Ok(Ok(relay_entries))) =
tokio::time::timeout(std::time::Duration::from_secs(5), rx).await
{
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis() as u64;
for entry in relay_entries {
// upsert as stub — empty bio/public_key means never directly connected
let stub = DirectoryEntry {
peer_id: entry.peer_id.clone(),
display_name: entry.display_name,
bio: String::new(),
public_key: String::new(),
last_seen: entry.last_seen.saturating_mul(1000).max(now - 86_400_000),
is_friend: false,
};
// preserve existing local data if we already know this peer
let _ = state.storage.save_directory_entry_if_new(&stub);
}
// re-run local search to get merged results
let entries2 = state
.storage
.load_directory()
.unwrap_or_default();
let mut results2: Vec<DirectoryEntry> = entries2
.into_values()
.filter(|entry| {
entry.display_name.to_lowercase().contains(&query_lower)
|| entry.peer_id.to_lowercase().contains(&query_lower)
})
.collect();
results2.sort_by(|a, b| b.last_seen.cmp(&a.last_seen));
return Ok(results2);
}
}
}
Ok(results) Ok(results)
}) })
} }
@ -329,6 +387,33 @@ pub async fn discover_global_peers(state: State<'_, AppState>) -> Result<(), Str
Ok(()) Ok(())
} }
// toggle relay discoverability at runtime and sync the setting to disk
#[tauri::command]
pub async fn set_relay_discoverable(
state: State<'_, AppState>,
enabled: bool,
) -> Result<(), String> {
ipc_log!("set_relay_discoverable", {
// persist setting
let mut settings = state.storage.load_settings().unwrap_or_default();
settings.relay_discoverable = enabled;
state
.storage
.save_settings(&settings)
.map_err(|e| format!("failed to save settings: {}", e))?;
// notify running node
let node_handle = state.node_handle.lock().await;
if let Some(ref handle) = *node_handle {
let _ = handle
.command_tx
.send(crate::node::NodeCommand::SetRelayDiscoverable { enabled })
.await;
}
Ok(())
})
}
// change relay address and restart the node // change relay address and restart the node
// used when default relay is unreachable or at capacity // used when default relay is unreachable or at capacity
#[tauri::command] #[tauri::command]

View File

@ -114,6 +114,7 @@ pub fn run() {
commands::identity::add_friend, commands::identity::add_friend,
commands::identity::remove_friend, commands::identity::remove_friend,
commands::identity::discover_global_peers, commands::identity::discover_global_peers,
commands::identity::set_relay_discoverable,
commands::identity::set_relay_address, commands::identity::set_relay_address,
commands::identity::reset_identity, commands::identity::reset_identity,
commands::identity::cache_avatar_icon, commands::identity::cache_avatar_icon,

View File

@ -1,3 +1,4 @@
use crate::protocol::directory::{DirectoryRequest, DirectoryResponse};
use crate::protocol::gif::{GifRequest, GifResponse}; use crate::protocol::gif::{GifRequest, GifResponse};
use libp2p::{ use libp2p::{
gossipsub, identify, kad, mdns, ping, relay, rendezvous, request_response::cbor, gossipsub, identify, kad, mdns, ping, relay, rendezvous, request_response::cbor,
@ -15,4 +16,6 @@ pub struct DuskBehaviour {
pub ping: ping::Behaviour, pub ping: ping::Behaviour,
// gif search: sends requests to the relay, receives responses // gif search: sends requests to the relay, receives responses
pub gif_service: cbor::Behaviour<GifRequest, GifResponse>, pub gif_service: cbor::Behaviour<GifRequest, GifResponse>,
// directory search: register/search/remove profiles on the relay
pub directory_service: cbor::Behaviour<DirectoryRequest, DirectoryResponse>,
} }

View File

@ -208,6 +208,21 @@ pub enum NodeCommand {
request: crate::protocol::gif::GifRequest, request: crate::protocol::gif::GifRequest,
reply: tokio::sync::oneshot::Sender<Result<crate::protocol::gif::GifResponse, String>>, reply: tokio::sync::oneshot::Sender<Result<crate::protocol::gif::GifResponse, String>>,
}, },
// register this peer's profile in the relay's persistent directory
DirectoryRegister,
// remove this peer's profile from the relay's directory
DirectoryRemove,
// search the relay's directory by display_name or peer_id
DirectorySearch {
query: String,
reply: tokio::sync::oneshot::Sender<
Result<Vec<crate::protocol::directory::DirectoryProfileEntry>, String>,
>,
},
// update the relay_discoverable flag at runtime (from settings toggle)
SetRelayDiscoverable {
enabled: bool,
},
} }
// events emitted from the node to the tauri frontend // events emitted from the node to the tauri frontend
@ -464,6 +479,20 @@ pub async fn start(
tokio::sync::oneshot::Sender<Result<crate::protocol::gif::GifResponse, String>>, tokio::sync::oneshot::Sender<Result<crate::protocol::gif::GifResponse, String>>,
> = HashMap::new(); > = HashMap::new();
// pending directory search replies keyed by request_response request id
let mut pending_directory_replies: HashMap<
libp2p::request_response::OutboundRequestId,
tokio::sync::oneshot::Sender<
Result<Vec<crate::protocol::directory::DirectoryProfileEntry>, String>,
>,
> = HashMap::new();
// relay_discoverable flag -- read from storage once at startup
let mut relay_discoverable = storage
.load_settings()
.map(|s| s.relay_discoverable)
.unwrap_or(true);
// relay reconnection state with exponential backoff // relay reconnection state with exponential backoff
let mut relay_backoff_secs = RELAY_INITIAL_BACKOFF_SECS; let mut relay_backoff_secs = RELAY_INITIAL_BACKOFF_SECS;
// deferred warning timer -- only notify the frontend after the grace // deferred warning timer -- only notify the frontend after the grace
@ -1035,6 +1064,18 @@ pub async fn start(
// any WAN peers are reachable, so this ensures remote // any WAN peers are reachable, so this ensures remote
// peers learn about us once the relay mesh is live // peers learn about us once the relay mesh is live
publish_profile(&mut swarm_instance, &node_keypair, &storage); publish_profile(&mut swarm_instance, &node_keypair, &storage);
// register profile in relay's persistent directory if discoverable
if relay_discoverable {
let profile = storage.load_profile().unwrap_or_default();
swarm_instance.behaviour_mut().directory_service.send_request(
&relay_peer_id,
crate::protocol::directory::DirectoryRequest::Register {
display_name: profile.display_name,
},
);
log::info!("directory: sent Register to relay");
}
} }
libp2p::swarm::SwarmEvent::Behaviour(behaviour::DuskBehaviourEvent::RelayClient(event)) => { libp2p::swarm::SwarmEvent::Behaviour(behaviour::DuskBehaviourEvent::RelayClient(event)) => {
log::debug!("relay client event: {:?}", event); log::debug!("relay client event: {:?}", event);
@ -1345,6 +1386,35 @@ pub async fn start(
// ignore inbound requests (we only send outbound) and other events // ignore inbound requests (we only send outbound) and other events
libp2p::swarm::SwarmEvent::Behaviour(behaviour::DuskBehaviourEvent::GifService(_)) => {} libp2p::swarm::SwarmEvent::Behaviour(behaviour::DuskBehaviourEvent::GifService(_)) => {}
// directory service response from relay
libp2p::swarm::SwarmEvent::Behaviour(behaviour::DuskBehaviourEvent::DirectoryService(
libp2p::request_response::Event::Message {
message: libp2p::request_response::Message::Response { request_id, response },
..
}
)) => {
if let Some(reply) = pending_directory_replies.remove(&request_id) {
match response {
crate::protocol::directory::DirectoryResponse::Results(entries) => {
let _ = reply.send(Ok(entries));
}
crate::protocol::directory::DirectoryResponse::Ok => {
let _ = reply.send(Ok(vec![]));
}
}
}
}
// directory service outbound failure
libp2p::swarm::SwarmEvent::Behaviour(behaviour::DuskBehaviourEvent::DirectoryService(
libp2p::request_response::Event::OutboundFailure { request_id, error, .. }
)) => {
log::warn!("directory: outbound failure: {:?}", error);
if let Some(reply) = pending_directory_replies.remove(&request_id) {
let _ = reply.send(Err(format!("directory request failed: {:?}", error)));
}
}
libp2p::swarm::SwarmEvent::Behaviour(behaviour::DuskBehaviourEvent::DirectoryService(_)) => {}
_ => {} _ => {}
} }
} }
@ -1643,6 +1713,70 @@ pub async fn start(
let _ = reply.send(Err("not connected to relay".to_string())); let _ = reply.send(Err("not connected to relay".to_string()));
} }
} }
Some(NodeCommand::DirectoryRegister) => {
if relay_reservation_active {
if let Some(rp) = relay_peer {
let profile = storage.load_profile().unwrap_or_default();
swarm_instance.behaviour_mut().directory_service.send_request(
&rp,
crate::protocol::directory::DirectoryRequest::Register {
display_name: profile.display_name,
},
);
log::info!("directory: sent Register (command)");
}
}
}
Some(NodeCommand::DirectoryRemove) => {
if relay_reservation_active {
if let Some(rp) = relay_peer {
swarm_instance.behaviour_mut().directory_service.send_request(
&rp,
crate::protocol::directory::DirectoryRequest::Remove,
);
log::info!("directory: sent Remove (command)");
}
}
}
Some(NodeCommand::DirectorySearch { query, reply }) => {
if let Some(rp) = relay_peer {
let request_id = swarm_instance
.behaviour_mut()
.directory_service
.send_request(
&rp,
crate::protocol::directory::DirectoryRequest::Search { query },
);
pending_directory_replies.insert(request_id, reply);
} else {
let _ = reply.send(Err("relay not connected".to_string()));
}
}
Some(NodeCommand::SetRelayDiscoverable { enabled }) => {
relay_discoverable = enabled;
if relay_reservation_active {
if enabled {
if let Some(rp) = relay_peer {
let profile = storage.load_profile().unwrap_or_default();
swarm_instance.behaviour_mut().directory_service.send_request(
&rp,
crate::protocol::directory::DirectoryRequest::Register {
display_name: profile.display_name,
},
);
log::info!("directory: registered after opt-in");
}
} else {
if let Some(rp) = relay_peer {
swarm_instance.behaviour_mut().directory_service.send_request(
&rp,
crate::protocol::directory::DirectoryRequest::Remove,
);
log::info!("directory: removed after opt-out");
}
}
}
}
} }
} }
} }

View File

@ -9,6 +9,7 @@ use libp2p::{
}; };
use super::behaviour::DuskBehaviour; use super::behaviour::DuskBehaviour;
use crate::protocol::directory::{DirectoryRequest, DirectoryResponse, DIRECTORY_PROTOCOL};
use crate::protocol::gif::{GifRequest, GifResponse, GIF_PROTOCOL}; use crate::protocol::gif::{GifRequest, GifResponse, GIF_PROTOCOL};
pub fn build_swarm( pub fn build_swarm(
@ -85,6 +86,12 @@ pub fn build_swarm(
request_response::Config::default() request_response::Config::default()
.with_request_timeout(Duration::from_secs(15)), .with_request_timeout(Duration::from_secs(15)),
), ),
// directory search via request-response to the relay (outbound only)
directory_service: cbor::Behaviour::<DirectoryRequest, DirectoryResponse>::new(
[(DIRECTORY_PROTOCOL, ProtocolSupport::Outbound)],
request_response::Config::default()
.with_request_timeout(Duration::from_secs(15)),
),
} }
})? })?
.with_swarm_config(|cfg| cfg.with_idle_connection_timeout(Duration::from_secs(300))) .with_swarm_config(|cfg| cfg.with_idle_connection_timeout(Duration::from_secs(300)))

View File

@ -0,0 +1,27 @@
// directory protocol types for the relay-backed peer discovery service.
// the client sends DirectoryRequests to the relay and receives DirectoryResponses.
use libp2p::StreamProtocol;
pub const DIRECTORY_PROTOCOL: StreamProtocol =
StreamProtocol::new("/dusk/directory/1.0.0");
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub enum DirectoryRequest {
Register { display_name: String },
Search { query: String },
Remove,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub enum DirectoryResponse {
Ok,
Results(Vec<DirectoryProfileEntry>),
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct DirectoryProfileEntry {
pub peer_id: String,
pub display_name: String,
pub last_seen: u64,
}

View File

@ -1,4 +1,5 @@
pub mod community; pub mod community;
pub mod directory;
pub mod gif; pub mod gif;
pub mod identity; pub mod identity;
pub mod messages; pub mod messages;

View File

@ -27,8 +27,12 @@ pub struct UserSettings {
pub font_size: String, pub font_size: String,
#[serde(default)] #[serde(default)]
pub custom_relay_addr: Option<String>, pub custom_relay_addr: Option<String>,
#[serde(default = "default_true")]
pub relay_discoverable: bool,
} }
fn default_true() -> bool { true }
impl Default for UserSettings { impl Default for UserSettings {
fn default() -> Self { fn default() -> Self {
Self { Self {
@ -43,6 +47,7 @@ impl Default for UserSettings {
message_display: "cozy".to_string(), message_display: "cozy".to_string(),
custom_relay_addr: None, custom_relay_addr: None,
font_size: "default".to_string(), font_size: "default".to_string(),
relay_discoverable: true,
} }
} }
} }
@ -739,6 +744,29 @@ impl DiskStorage {
Ok(()) Ok(())
} }
// upsert a directory entry from the relay — updates display_name and last_seen but preserves bio, public_key, and is_friend
pub fn save_directory_entry_if_new(&self, entry: &DirectoryEntry) -> Result<(), io::Error> {
let conn = self.open_conn()?;
conn.execute(
"INSERT INTO directory_entries (
peer_id, display_name, bio, public_key, last_seen, is_friend
) VALUES (?1, ?2, ?3, ?4, ?5, ?6)
ON CONFLICT(peer_id) DO UPDATE SET
display_name = excluded.display_name,
last_seen = CASE WHEN excluded.last_seen > last_seen THEN excluded.last_seen ELSE last_seen END",
params![
entry.peer_id,
entry.display_name,
entry.bio,
entry.public_key,
entry.last_seen as i64,
if entry.is_friend { 1_i64 } else { 0_i64 }
],
)
.map_err(sqlite_to_io_error)?;
Ok(())
}
// load the entire peer directory // load the entire peer directory
pub fn load_directory(&self) -> Result<HashMap<String, DirectoryEntry>, io::Error> { pub fn load_directory(&self) -> Result<HashMap<String, DirectoryEntry>, io::Error> {
let conn = self.open_conn()?; let conn = self.open_conn()?;

View File

@ -167,6 +167,11 @@ const SignUpScreen: Component<SignUpScreenProps> = (props) => {
an ed25519 keypair will be generated and stored locally on your an ed25519 keypair will be generated and stored locally on your
device device
</p> </p>
<p class="text-[11px] font-mono text-white/30 text-center mt-2 leading-relaxed">
your username will be stored on the relay server so others can
find you even when you're offline.{" "}
<span class="text-white/40">you can turn this off in settings.</span>
</p>
</div> </div>
</Match> </Match>
</Switch> </Switch>

View File

@ -16,6 +16,7 @@ import {
Users, Users,
Copy, Copy,
Check, Check,
WifiOff,
} from "lucide-solid"; } from "lucide-solid";
import Avatar from "../common/Avatar"; import Avatar from "../common/Avatar";
import Button from "../common/Button"; import Button from "../common/Button";
@ -263,7 +264,9 @@ const UserDirectoryModal: Component<UserDirectoryModalProps> = (props) => {
<p class="text-[14px] text-white/20"> <p class="text-[14px] text-white/20">
{activeTab() === "friends" {activeTab() === "friends"
? "add friends from the all peers tab" ? "add friends from the all peers tab"
: "peers will appear as you join communities"} : searchQuery().trim().length > 0
? "try searching by peer id or check your relay connection"
: "peers will appear as you join communities or search by name"}
</p> </p>
</div> </div>
} }
@ -283,6 +286,12 @@ const UserDirectoryModal: Component<UserDirectoryModalProps> = (props) => {
friend friend
</span> </span>
</Show> </Show>
<Show when={!peer.public_key}>
<span class="flex items-center gap-1 text-[10px] font-mono uppercase tracking-[0.05em] text-white/30 px-1.5 py-0.5 border border-white/10 shrink-0">
<WifiOff size={8} />
offline
</span>
</Show>
</div> </div>
<Show when={peer.bio}> <Show when={peer.bio}>
<p class="text-[13px] text-white/40 truncate"> <p class="text-[13px] text-white/40 truncate">

View File

@ -21,11 +21,12 @@ import {
toggleMessagePreview, toggleMessagePreview,
toggleShowOnlineStatus, toggleShowOnlineStatus,
toggleAllowDMsFromAnyone, toggleAllowDMsFromAnyone,
toggleRelayDiscoverable,
setMessageDisplay, setMessageDisplay,
setFontSize, setFontSize,
} from "../../stores/settings"; } from "../../stores/settings";
import { identity, updateIdentity } from "../../stores/identity"; import { identity, updateIdentity } from "../../stores/identity";
import { updateProfile } from "../../lib/tauri"; import { updateProfile, setRelayDiscoverable } from "../../lib/tauri";
import type { UserStatus } from "../../lib/types"; import type { UserStatus } from "../../lib/types";
import Avatar from "../common/Avatar"; import Avatar from "../common/Avatar";
import Button from "../common/Button"; import Button from "../common/Button";
@ -378,6 +379,19 @@ const PrivacySection: Component<{
checked={current().allow_dms_from_anyone} checked={current().allow_dms_from_anyone}
onChange={toggleAllowDMsFromAnyone} onChange={toggleAllowDMsFromAnyone}
/> />
<ToggleRow
label="discoverable on relay"
description="lets others find you by username even when you're offline. turning this off removes your profile from the relay index."
checked={current().relay_discoverable ?? true}
onChange={async () => {
toggleRelayDiscoverable();
try {
await setRelayDiscoverable(!current().relay_discoverable);
} catch (e) {
console.error("failed to set relay discoverable:", e);
}
}}
/>
{/* danger zone */} {/* danger zone */}
<div class="mt-8 pt-6 border-t border-red-500/20"> <div class="mt-8 pt-6 border-t border-red-500/20">

View File

@ -296,6 +296,10 @@ export async function discoverGlobalPeers(): Promise<void> {
return invoke("discover_global_peers"); return invoke("discover_global_peers");
} }
export async function setRelayDiscoverable(enabled: boolean): Promise<void> {
return invoke("set_relay_discoverable", { enabled });
}
export async function setRelayAddress(relayAddr: string): Promise<void> { export async function setRelayAddress(relayAddr: string): Promise<void> {
return invoke("set_relay_address", { relayAddr }); return invoke("set_relay_address", { relayAddr });
} }

View File

@ -68,6 +68,9 @@ export interface UserSettings {
// network // network
custom_relay_addr?: string; custom_relay_addr?: string;
// discovery
relay_discoverable: boolean;
} }
export interface CommunityMeta { export interface CommunityMeta {

View File

@ -11,6 +11,7 @@ const defaultSettings: UserSettings = {
enable_message_preview: true, enable_message_preview: true,
show_online_status: true, show_online_status: true,
allow_dms_from_anyone: true, allow_dms_from_anyone: true,
relay_discoverable: true,
message_display: "cozy", message_display: "cozy",
font_size: "default", font_size: "default",
}; };
@ -86,6 +87,13 @@ export function toggleAllowDMsFromAnyone() {
})); }));
} }
export function toggleRelayDiscoverable() {
setSettings((prev) => ({
...prev,
relay_discoverable: !prev.relay_discoverable,
}));
}
export function setMessageDisplay(mode: "cozy" | "compact") { export function setMessageDisplay(mode: "cozy" | "compact") {
updateSettings({ message_display: mode }); updateSettings({ message_display: mode });
} }