Skip to content

Commit e105c9e

Browse files
wesbillmanclaude
andauthored
feat(desktop): add relay membership admin UI in settings (#454)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 088c4e7 commit e105c9e

13 files changed

Lines changed: 904 additions & 4 deletions

File tree

desktop/scripts/check-file-sizes.mjs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,8 @@ const overrides = new Map([
5656
["src/features/agents/ui/useTeamActions.ts", 510], // team CRUD + export + import + import-update orchestration with query invalidation
5757
["src/features/agents/ui/CreateAgentDialog.tsx", 685], // provider selector + config form + schema-typed config coercion + required field validation + locked scopes
5858
["src/features/channels/ui/AddChannelBotDialog.tsx", 640], // provider mode: Run on selector, trust warning, probe effect, single-agent enforcement, provider warnings display
59-
["src/shared/api/types.ts", 560], // persona provider/model fields + forum types + workflow type re-exports + ephemeral channel TTL fields + mcpToolsets + sourcePack + UpdateManagedAgentInput edit fields + UserStatus/UserStatusLookup (NIP-38)
60-
["src-tauri/src/events.rs", 555], // event builders + build_huddle_guidelines (kind:48106) + post_event_raw transport helper + participant p-tag on join/leave
59+
["src/shared/api/types.ts", 570], // persona provider/model fields + forum types + workflow type re-exports + ephemeral channel TTL fields + mcpToolsets + sourcePack + UpdateManagedAgentInput edit fields + UserStatus/UserStatusLookup (NIP-38) + RelayMember/RelayMemberRole types
60+
["src-tauri/src/events.rs", 565], // event builders + build_huddle_guidelines (kind:48106) + post_event_raw transport helper + participant p-tag on join/leave + NIP-43 relay admin builders (add/remove/change-role) + check_relay_role
6161
["src-tauri/src/huddle/kokoro.rs", 980], // Kokoro ONNX TTS engine + three-tier G2P + ARPAbet→IPA + CoreML + synth_chunk() public API + style validation + hyphenated compound splitting + 23 unit tests
6262
["src-tauri/src/huddle/mod.rs", 1020], // huddle state machine + Tauri commands + sync protocol doc; state/relay/pipeline extracted + emit_huddle_state_changed wiring
6363
["src-tauri/src/huddle/models.rs", 850], // model download manager for Moonshine STT + Kokoro TTS with streaming downloads + SHA-256 verification + Rust-native tar extraction + version manifest + atomic swap + hot-start signaling
@@ -68,7 +68,7 @@ const overrides = new Map([
6868
["src-tauri/src/relay.rs", 510], // +4 lines for NIP-OA auth tag injection in profile sync (build_profile_event) + verification test
6969
["src-tauri/src/commands/pairing.rs", 550], // NIP-AB pairing actor: 3 Tauri commands + background WS task + NIP-42 auth + event parsing helpers
7070
["src-tauri/src/lib.rs", 715], // +4 lines for PairingHandle managed state + 3 pairing command registrations
71-
["src/shared/api/tauri.ts", 1140], // pairing command wrappers + applyWorkspace + NIP-44 encrypt/decrypt wrappers + observer_url field
71+
["src/shared/api/tauri.ts", 1210], // pairing command wrappers + applyWorkspace + NIP-44 encrypt/decrypt wrappers + observer_url field + relay member API functions (list/get/add/remove/change-role)
7272
]);
7373

7474
async function walkFiles(directory) {

desktop/src-tauri/src/commands/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ mod messages;
1212
pub mod pairing;
1313
mod personas;
1414
mod profile;
15+
mod relay_members;
1516
mod social;
1617
mod teams;
1718
pub mod tokens;
@@ -31,6 +32,7 @@ pub use messages::*;
3132
pub use pairing::*;
3233
pub use personas::*;
3334
pub use profile::*;
35+
pub use relay_members::*;
3436
pub use social::*;
3537
pub use teams::*;
3638
pub use tokens::*;
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
use reqwest::Method;
2+
use tauri::State;
3+
4+
use crate::{
5+
app_state::AppState,
6+
events,
7+
relay::{api_path, build_authed_request, send_json_request, submit_event},
8+
};
9+
10+
#[tauri::command]
11+
pub async fn list_relay_members(state: State<'_, AppState>) -> Result<serde_json::Value, String> {
12+
let request = build_authed_request(
13+
&state.http_client,
14+
Method::GET,
15+
&api_path(&["relay", "members"]),
16+
&state,
17+
)?;
18+
send_json_request(request).await
19+
}
20+
21+
#[tauri::command]
22+
pub async fn get_my_relay_membership(
23+
state: State<'_, AppState>,
24+
) -> Result<serde_json::Value, String> {
25+
let request = build_authed_request(
26+
&state.http_client,
27+
Method::GET,
28+
&api_path(&["relay", "members", "me"]),
29+
&state,
30+
)?;
31+
send_json_request(request).await
32+
}
33+
34+
#[tauri::command]
35+
pub async fn add_relay_member(
36+
target_pubkey: String,
37+
role: String,
38+
state: State<'_, AppState>,
39+
) -> Result<serde_json::Value, String> {
40+
let builder = events::build_relay_admin_add(&target_pubkey, &role)?;
41+
let result = submit_event(builder, &state).await?;
42+
Ok(serde_json::to_value(result).map_err(|e| e.to_string())?)
43+
}
44+
45+
#[tauri::command]
46+
pub async fn remove_relay_member(
47+
target_pubkey: String,
48+
state: State<'_, AppState>,
49+
) -> Result<serde_json::Value, String> {
50+
let builder = events::build_relay_admin_remove(&target_pubkey)?;
51+
let result = submit_event(builder, &state).await?;
52+
Ok(serde_json::to_value(result).map_err(|e| e.to_string())?)
53+
}
54+
55+
#[tauri::command]
56+
pub async fn change_relay_member_role(
57+
target_pubkey: String,
58+
new_role: String,
59+
state: State<'_, AppState>,
60+
) -> Result<serde_json::Value, String> {
61+
let builder = events::build_relay_admin_change_role(&target_pubkey, &new_role)?;
62+
let result = submit_event(builder, &state).await?;
63+
Ok(serde_json::to_value(result).map_err(|e| e.to_string())?)
64+
}

desktop/src-tauri/src/events.rs

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -442,6 +442,53 @@ pub fn build_note(
442442
Ok(EventBuilder::new(Kind::TextNote, content).tags(tags))
443443
}
444444

445+
// ── Relay admin (NIP-43) ────────────────────────────────────────────────────
446+
447+
/// Allowed relay member roles for NIP-43 admin commands.
448+
const VALID_RELAY_ROLES: &[&str] = &["owner", "admin", "member"];
449+
450+
fn check_relay_role(role: &str) -> Result<(), String> {
451+
if !VALID_RELAY_ROLES.contains(&role) {
452+
return Err(format!(
453+
"invalid relay role \"{role}\" (expected one of: {})",
454+
VALID_RELAY_ROLES.join(", ")
455+
));
456+
}
457+
Ok(())
458+
}
459+
460+
/// Kind 9030 — add a pubkey to the relay member list.
461+
pub fn build_relay_admin_add(target_pubkey: &str, role: &str) -> Result<EventBuilder, String> {
462+
check_pubkey(target_pubkey)?;
463+
check_relay_role(role)?;
464+
let tags = vec![
465+
tag(vec!["p", &target_pubkey.to_ascii_lowercase()])?,
466+
tag(vec!["role", role])?,
467+
];
468+
Ok(EventBuilder::new(Kind::Custom(9030), "").tags(tags))
469+
}
470+
471+
/// Kind 9031 — remove a pubkey from the relay member list.
472+
pub fn build_relay_admin_remove(target_pubkey: &str) -> Result<EventBuilder, String> {
473+
check_pubkey(target_pubkey)?;
474+
let tags = vec![tag(vec!["p", &target_pubkey.to_ascii_lowercase()])?];
475+
Ok(EventBuilder::new(Kind::Custom(9031), "").tags(tags))
476+
}
477+
478+
/// Kind 9032 — change the role of an existing relay member.
479+
pub fn build_relay_admin_change_role(
480+
target_pubkey: &str,
481+
new_role: &str,
482+
) -> Result<EventBuilder, String> {
483+
check_pubkey(target_pubkey)?;
484+
check_relay_role(new_role)?;
485+
let tags = vec![
486+
tag(vec!["p", &target_pubkey.to_ascii_lowercase()])?,
487+
tag(vec!["role", new_role])?,
488+
];
489+
Ok(EventBuilder::new(Kind::Custom(9032), "").tags(tags))
490+
}
491+
445492
/// Maximum contacts per contact list event.
446493
const MAX_CONTACTS: usize = 10_000;
447494

desktop/src-tauri/src/lib.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -413,6 +413,11 @@ pub fn run() {
413413
mint_token,
414414
revoke_token,
415415
revoke_all_tokens,
416+
list_relay_members,
417+
get_my_relay_membership,
418+
add_relay_member,
419+
remove_relay_member,
420+
change_relay_member_role,
416421
list_relay_agents,
417422
list_managed_agents,
418423
create_managed_agent,
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
2+
3+
import {
4+
addRelayMember,
5+
changeRelayMemberRole,
6+
getMyRelayMembership,
7+
listRelayMembers,
8+
removeRelayMember,
9+
} from "@/shared/api/tauri";
10+
import type { RelayMember } from "@/shared/api/types";
11+
12+
export const relayMembersQueryKey = ["relayMembers"] as const;
13+
export const myRelayMembershipQueryKey = ["myRelayMembership"] as const;
14+
15+
export function useRelayMembersQuery() {
16+
return useQuery({
17+
queryKey: relayMembersQueryKey,
18+
queryFn: listRelayMembers,
19+
staleTime: 30_000,
20+
});
21+
}
22+
23+
export function useMyRelayMembershipQuery() {
24+
return useQuery({
25+
queryKey: myRelayMembershipQueryKey,
26+
queryFn: getMyRelayMembership,
27+
staleTime: 60_000,
28+
});
29+
}
30+
31+
export function useAddRelayMemberMutation() {
32+
const queryClient = useQueryClient();
33+
34+
return useMutation({
35+
mutationFn: ({ pubkey, role }: { pubkey: string; role: string }) =>
36+
addRelayMember(pubkey, role),
37+
onMutate: async ({ pubkey, role }) => {
38+
await queryClient.cancelQueries({ queryKey: relayMembersQueryKey });
39+
const previous =
40+
queryClient.getQueryData<RelayMember[]>(relayMembersQueryKey);
41+
42+
queryClient.setQueryData<RelayMember[]>(relayMembersQueryKey, (old) => [
43+
...(old ?? []),
44+
{
45+
pubkey,
46+
role: role as RelayMember["role"],
47+
addedBy: null,
48+
createdAt: new Date().toISOString(),
49+
},
50+
]);
51+
52+
return { previous };
53+
},
54+
onError: (_err, _vars, context) => {
55+
if (context?.previous) {
56+
queryClient.setQueryData(relayMembersQueryKey, context.previous);
57+
}
58+
},
59+
onSettled: async () => {
60+
await queryClient.invalidateQueries({ queryKey: relayMembersQueryKey });
61+
},
62+
});
63+
}
64+
65+
export function useRemoveRelayMemberMutation() {
66+
const queryClient = useQueryClient();
67+
68+
return useMutation({
69+
mutationFn: (pubkey: string) => removeRelayMember(pubkey),
70+
onMutate: async (pubkey) => {
71+
await queryClient.cancelQueries({ queryKey: relayMembersQueryKey });
72+
const previous =
73+
queryClient.getQueryData<RelayMember[]>(relayMembersQueryKey);
74+
75+
queryClient.setQueryData<RelayMember[]>(relayMembersQueryKey, (old) =>
76+
old?.filter((m) => m.pubkey.toLowerCase() !== pubkey.toLowerCase()),
77+
);
78+
79+
return { previous };
80+
},
81+
onError: (_err, _pubkey, context) => {
82+
if (context?.previous) {
83+
queryClient.setQueryData(relayMembersQueryKey, context.previous);
84+
}
85+
},
86+
onSettled: async () => {
87+
await queryClient.invalidateQueries({ queryKey: relayMembersQueryKey });
88+
},
89+
});
90+
}
91+
92+
export function useChangeRelayMemberRoleMutation() {
93+
const queryClient = useQueryClient();
94+
95+
return useMutation({
96+
mutationFn: ({ pubkey, newRole }: { pubkey: string; newRole: string }) =>
97+
changeRelayMemberRole(pubkey, newRole),
98+
onMutate: async ({ pubkey, newRole }) => {
99+
await queryClient.cancelQueries({ queryKey: relayMembersQueryKey });
100+
const previous =
101+
queryClient.getQueryData<RelayMember[]>(relayMembersQueryKey);
102+
103+
queryClient.setQueryData<RelayMember[]>(relayMembersQueryKey, (old) =>
104+
old?.map((m) =>
105+
m.pubkey.toLowerCase() === pubkey.toLowerCase()
106+
? { ...m, role: newRole as RelayMember["role"] }
107+
: m,
108+
),
109+
);
110+
111+
return { previous };
112+
},
113+
onError: (_err, _vars, context) => {
114+
if (context?.previous) {
115+
queryClient.setQueryData(relayMembersQueryKey, context.previous);
116+
}
117+
},
118+
onSettled: async () => {
119+
await queryClient.invalidateQueries({ queryKey: relayMembersQueryKey });
120+
},
121+
});
122+
}

0 commit comments

Comments
 (0)