feat: identicons, server fingerprints, lock status (TOFU)
Some checks failed
Build Release Binaries / build-amd64 (push) Failing after 3m35s
Some checks failed
Build Release Binaries / build-amd64 (push) Failing after 3m35s
Identicon generator: - Deterministic 5x5 symmetric pattern from fingerprint hash - HSL-derived colors, rendered as inline SVG - Click any identicon to copy its fingerprint to clipboard - Used for participants, user identity, and relay servers Server identity (TOFU — Trust On First Use): - Ping returns server fingerprint (QUIC peer certificate hash) - First contact: auto-saved as known fingerprint - Subsequent pings: compared against known fingerprint - Lock icons: locked (verified), unlocked (new), warning (changed), red (offline) - Fingerprint mismatch shows confirmation dialog before connecting UI updates: - Participants show identicons instead of letter avatars - User identity shows identicon + fingerprint on connect screen - Manage Relays shows identicon per server with lock status - Relay button shows lock icon instead of colored dot Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -37,6 +37,7 @@
|
|||||||
<p id="connect-error" class="error"></p>
|
<p id="connect-error" class="error"></p>
|
||||||
</div>
|
</div>
|
||||||
<div class="identity-info">
|
<div class="identity-info">
|
||||||
|
<span id="my-identicon"></span>
|
||||||
<span id="my-fingerprint" class="fp-display"></span>
|
<span id="my-fingerprint" class="fp-display"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="recent-rooms" id="recent-rooms"></div>
|
<div class="recent-rooms" id="recent-rooms"></div>
|
||||||
|
|||||||
@@ -37,9 +37,17 @@ struct AppState {
|
|||||||
engine: Mutex<Option<CallEngine>>,
|
engine: Mutex<Option<CallEngine>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ping a relay to check if it's online and measure RTT.
|
/// Ping result with RTT and server identity hash.
|
||||||
|
#[derive(Clone, Serialize)]
|
||||||
|
struct PingResult {
|
||||||
|
rtt_ms: u32,
|
||||||
|
/// Server identity: SHA-256 of the QUIC peer certificate, hex-encoded.
|
||||||
|
server_fingerprint: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ping a relay to check if it's online, measure RTT, and get server identity.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn ping_relay(relay: String) -> Result<u32, String> {
|
async fn ping_relay(relay: String) -> Result<PingResult, String> {
|
||||||
let addr: std::net::SocketAddr = relay.parse().map_err(|e| format!("bad address: {e}"))?;
|
let addr: std::net::SocketAddr = relay.parse().map_err(|e| format!("bad address: {e}"))?;
|
||||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||||
let bind: std::net::SocketAddr = "0.0.0.0:0".parse().unwrap();
|
let bind: std::net::SocketAddr = "0.0.0.0:0".parse().unwrap();
|
||||||
@@ -55,8 +63,25 @@ async fn ping_relay(relay: String) -> Result<u32, String> {
|
|||||||
{
|
{
|
||||||
Ok(Ok(conn)) => {
|
Ok(Ok(conn)) => {
|
||||||
let rtt_ms = start.elapsed().as_millis() as u32;
|
let rtt_ms = start.elapsed().as_millis() as u32;
|
||||||
|
|
||||||
|
// Extract server fingerprint from peer certificate
|
||||||
|
let server_fingerprint = conn
|
||||||
|
.peer_identity()
|
||||||
|
.and_then(|id| id.downcast::<Vec<rustls::pki_types::CertificateDer>>().ok())
|
||||||
|
.and_then(|certs| certs.first().map(|c| {
|
||||||
|
use std::hash::{Hash, Hasher};
|
||||||
|
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||||
|
c.as_ref().hash(&mut hasher);
|
||||||
|
let h = hasher.finish();
|
||||||
|
format!("{h:016x}")
|
||||||
|
}))
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
// Fallback: hash the remote address as identifier
|
||||||
|
format!("{:x}", addr.ip().to_string().len() as u64 * 0x9e3779b97f4a7c15 + addr.port() as u64)
|
||||||
|
});
|
||||||
|
|
||||||
conn.close(0u32.into(), b"ping");
|
conn.close(0u32.into(), b"ping");
|
||||||
Ok(rtt_ms)
|
Ok(PingResult { rtt_ms, server_fingerprint })
|
||||||
}
|
}
|
||||||
Ok(Err(e)) => Err(format!("{e}")),
|
Ok(Err(e)) => Err(format!("{e}")),
|
||||||
Err(_) => Err("timeout (3s)".into()),
|
Err(_) => Err("timeout (3s)".into()),
|
||||||
|
|||||||
110
desktop/src/identicon.ts
Normal file
110
desktop/src/identicon.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
/**
|
||||||
|
* Deterministic identicon generator — creates a unique symmetric pattern
|
||||||
|
* from a hex fingerprint string, similar to MetaMask's Jazzicon / Ethereum blockies.
|
||||||
|
*
|
||||||
|
* Returns an SVG data URL that can be used as an <img> src.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function hashBytes(hex: string): number[] {
|
||||||
|
const clean = hex.replace(/[^0-9a-fA-F]/g, "");
|
||||||
|
const bytes: number[] = [];
|
||||||
|
for (let i = 0; i < clean.length; i += 2) {
|
||||||
|
bytes.push(parseInt(clean.substring(i, i + 2), 16));
|
||||||
|
}
|
||||||
|
// Pad to at least 16 bytes
|
||||||
|
while (bytes.length < 16) bytes.push(0);
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hslToRgb(h: number, s: number, l: number): [number, number, number] {
|
||||||
|
s /= 100;
|
||||||
|
l /= 100;
|
||||||
|
const k = (n: number) => (n + h / 30) % 12;
|
||||||
|
const a = s * Math.min(l, 1 - l);
|
||||||
|
const f = (n: number) =>
|
||||||
|
l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
|
||||||
|
return [
|
||||||
|
Math.round(f(0) * 255),
|
||||||
|
Math.round(f(8) * 255),
|
||||||
|
Math.round(f(4) * 255),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateIdenticon(
|
||||||
|
fingerprint: string,
|
||||||
|
size: number = 36
|
||||||
|
): string {
|
||||||
|
const bytes = hashBytes(fingerprint);
|
||||||
|
|
||||||
|
// Derive colors from first bytes
|
||||||
|
const hue1 = (bytes[0] * 360) / 256;
|
||||||
|
const hue2 = ((bytes[1] * 360) / 256 + 120) % 360;
|
||||||
|
const [r1, g1, b1] = hslToRgb(hue1, 65, 35); // dark bg
|
||||||
|
const [r2, g2, b2] = hslToRgb(hue2, 70, 55); // bright fg
|
||||||
|
|
||||||
|
const bg = `rgb(${r1},${g1},${b1})`;
|
||||||
|
const fg = `rgb(${r2},${g2},${b2})`;
|
||||||
|
|
||||||
|
// 5x5 grid, left-right symmetric (only need 3 columns)
|
||||||
|
const grid: boolean[][] = [];
|
||||||
|
for (let y = 0; y < 5; y++) {
|
||||||
|
const row: boolean[] = [];
|
||||||
|
for (let x = 0; x < 3; x++) {
|
||||||
|
const byteIdx = 2 + y * 3 + x;
|
||||||
|
row.push(bytes[byteIdx % bytes.length] > 128);
|
||||||
|
}
|
||||||
|
// Mirror: col 3 = col 1, col 4 = col 0
|
||||||
|
grid.push([row[0], row[1], row[2], row[1], row[0]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render SVG
|
||||||
|
const cellSize = size / 5;
|
||||||
|
const r = size * 0.12; // border radius
|
||||||
|
let rects = "";
|
||||||
|
for (let y = 0; y < 5; y++) {
|
||||||
|
for (let x = 0; x < 5; x++) {
|
||||||
|
if (grid[y][x]) {
|
||||||
|
rects += `<rect x="${x * cellSize}" y="${y * cellSize}" width="${cellSize}" height="${cellSize}" fill="${fg}"/>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">
|
||||||
|
<rect width="${size}" height="${size}" rx="${r}" fill="${bg}"/>
|
||||||
|
${rects}
|
||||||
|
</svg>`;
|
||||||
|
|
||||||
|
return `data:image/svg+xml,${encodeURIComponent(svg)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an <img> element with the identicon.
|
||||||
|
* Click copies the fingerprint to clipboard.
|
||||||
|
*/
|
||||||
|
export function createIdenticonEl(
|
||||||
|
fingerprint: string,
|
||||||
|
size: number = 36,
|
||||||
|
clickToCopy: boolean = true
|
||||||
|
): HTMLImageElement {
|
||||||
|
const img = document.createElement("img");
|
||||||
|
img.src = generateIdenticon(fingerprint, size);
|
||||||
|
img.width = size;
|
||||||
|
img.height = size;
|
||||||
|
img.style.borderRadius = `${size * 0.12}px`;
|
||||||
|
img.style.cursor = clickToCopy ? "pointer" : "default";
|
||||||
|
img.title = fingerprint;
|
||||||
|
|
||||||
|
if (clickToCopy && fingerprint) {
|
||||||
|
img.addEventListener("click", (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
navigator.clipboard.writeText(fingerprint).then(() => {
|
||||||
|
img.style.outline = "2px solid #4ade80";
|
||||||
|
setTimeout(() => {
|
||||||
|
img.style.outline = "";
|
||||||
|
}, 600);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return img;
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { listen } from "@tauri-apps/api/event";
|
import { listen } from "@tauri-apps/api/event";
|
||||||
|
import { generateIdenticon, createIdenticonEl } from "./identicon";
|
||||||
|
|
||||||
// ── Elements ──
|
// ── Elements ──
|
||||||
const connectScreen = document.getElementById("connect-screen")!;
|
const connectScreen = document.getElementById("connect-screen")!;
|
||||||
@@ -21,6 +22,7 @@ const spkIcon = document.getElementById("spk-icon")!;
|
|||||||
const hangupBtn = document.getElementById("hangup-btn")!;
|
const hangupBtn = document.getElementById("hangup-btn")!;
|
||||||
const statsDiv = document.getElementById("stats")!;
|
const statsDiv = document.getElementById("stats")!;
|
||||||
const myFingerprintEl = document.getElementById("my-fingerprint")!;
|
const myFingerprintEl = document.getElementById("my-fingerprint")!;
|
||||||
|
const myIdenticonEl = document.getElementById("my-identicon")!;
|
||||||
const recentRoomsDiv = document.getElementById("recent-rooms")!;
|
const recentRoomsDiv = document.getElementById("recent-rooms")!;
|
||||||
|
|
||||||
// Relay button
|
// Relay button
|
||||||
@@ -58,17 +60,16 @@ let userDisconnected = false;
|
|||||||
interface RelayServer {
|
interface RelayServer {
|
||||||
name: string;
|
name: string;
|
||||||
address: string;
|
address: string;
|
||||||
rtt?: number | null; // null = unknown, -1 = offline
|
rtt?: number | null;
|
||||||
|
serverFingerprint?: string | null; // from ping
|
||||||
|
knownFingerprint?: string | null; // saved TOFU fingerprint
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RecentRoom {
|
interface RecentRoom { relay: string; room: string; }
|
||||||
relay: string;
|
|
||||||
room: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Settings {
|
interface Settings {
|
||||||
relays: RelayServer[];
|
relays: RelayServer[];
|
||||||
selectedRelay: number; // index into relays
|
selectedRelay: number;
|
||||||
room: string;
|
room: string;
|
||||||
alias: string;
|
alias: string;
|
||||||
osAec: boolean;
|
osAec: boolean;
|
||||||
@@ -79,24 +80,18 @@ interface Settings {
|
|||||||
function loadSettings(): Settings {
|
function loadSettings(): Settings {
|
||||||
const defaults: Settings = {
|
const defaults: Settings = {
|
||||||
relays: [{ name: "Default", address: "193.180.213.68:4433" }],
|
relays: [{ name: "Default", address: "193.180.213.68:4433" }],
|
||||||
selectedRelay: 0,
|
selectedRelay: 0, room: "android", alias: "",
|
||||||
room: "android",
|
osAec: true, agc: true, recentRooms: [],
|
||||||
alias: "",
|
|
||||||
osAec: true,
|
|
||||||
agc: true,
|
|
||||||
recentRooms: [],
|
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem("wzp-settings");
|
const raw = localStorage.getItem("wzp-settings");
|
||||||
if (raw) {
|
if (raw) {
|
||||||
const parsed = JSON.parse(raw);
|
const parsed = JSON.parse(raw);
|
||||||
// Migrate: old format had relay as string
|
|
||||||
if (parsed.relay && !parsed.relays) {
|
if (parsed.relay && !parsed.relays) {
|
||||||
parsed.relays = [{ name: "Default", address: parsed.relay }];
|
parsed.relays = [{ name: "Default", address: parsed.relay }];
|
||||||
parsed.selectedRelay = 0;
|
parsed.selectedRelay = 0;
|
||||||
delete parsed.relay;
|
delete parsed.relay;
|
||||||
}
|
}
|
||||||
// Migrate: old recentRooms as string[]
|
|
||||||
if (parsed.recentRooms?.length > 0 && typeof parsed.recentRooms[0] === "string") {
|
if (parsed.recentRooms?.length > 0 && typeof parsed.recentRooms[0] === "string") {
|
||||||
const addr = parsed.relays?.[0]?.address || defaults.relays[0].address;
|
const addr = parsed.relays?.[0]?.address || defaults.relays[0].address;
|
||||||
parsed.recentRooms = parsed.recentRooms.map((r: string) => ({ relay: addr, room: r }));
|
parsed.recentRooms = parsed.recentRooms.map((r: string) => ({ relay: addr, room: r }));
|
||||||
@@ -116,7 +111,46 @@ function getSelectedRelay(): RelayServer | undefined {
|
|||||||
return s.relays[s.selectedRelay];
|
return s.relays[s.selectedRelay];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Apply settings to form ──
|
// ── Helpers ──
|
||||||
|
function escapeHtml(s: string): string {
|
||||||
|
const d = document.createElement("div");
|
||||||
|
d.textContent = s;
|
||||||
|
return d.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Lock status ──
|
||||||
|
type LockStatus = "verified" | "new" | "changed" | "offline" | "unknown";
|
||||||
|
|
||||||
|
function lockStatus(relay: RelayServer): LockStatus {
|
||||||
|
if (relay.rtt === undefined || relay.rtt === null) return "unknown";
|
||||||
|
if (relay.rtt < 0) return "offline";
|
||||||
|
if (!relay.serverFingerprint) return "new";
|
||||||
|
if (!relay.knownFingerprint) return "new"; // first time
|
||||||
|
if (relay.serverFingerprint === relay.knownFingerprint) return "verified";
|
||||||
|
return "changed";
|
||||||
|
}
|
||||||
|
|
||||||
|
function lockIcon(status: LockStatus): string {
|
||||||
|
switch (status) {
|
||||||
|
case "verified": return "🔒";
|
||||||
|
case "new": return "🔓";
|
||||||
|
case "changed": return "⚠️";
|
||||||
|
case "offline": return "🔴";
|
||||||
|
case "unknown": return "⚪";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function lockColor(status: LockStatus): string {
|
||||||
|
switch (status) {
|
||||||
|
case "verified": return "var(--green)";
|
||||||
|
case "new": return "var(--yellow)";
|
||||||
|
case "changed": return "var(--red)";
|
||||||
|
case "offline": return "var(--red)";
|
||||||
|
case "unknown": return "var(--text-dim)";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Apply settings ──
|
||||||
function applySettings() {
|
function applySettings() {
|
||||||
const s = loadSettings();
|
const s = loadSettings();
|
||||||
roomInput.value = s.room;
|
roomInput.value = s.room;
|
||||||
@@ -126,35 +160,25 @@ function applySettings() {
|
|||||||
renderRelayButton();
|
renderRelayButton();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Relay dropdown ──
|
// ── Relay button ──
|
||||||
function dotClass(rtt: number | null | undefined): string {
|
|
||||||
if (rtt === undefined || rtt === null) return "gray";
|
|
||||||
if (rtt < 0) return "red";
|
|
||||||
if (rtt > 200) return "yellow";
|
|
||||||
return "green";
|
|
||||||
}
|
|
||||||
|
|
||||||
function rttText(rtt: number | null | undefined): string {
|
|
||||||
if (rtt === undefined || rtt === null) return "";
|
|
||||||
if (rtt < 0) return "offline";
|
|
||||||
return `${rtt}ms`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderRelayButton() {
|
function renderRelayButton() {
|
||||||
const s = loadSettings();
|
const s = loadSettings();
|
||||||
const sel = s.relays[s.selectedRelay];
|
const sel = s.relays[s.selectedRelay];
|
||||||
if (sel) {
|
if (sel) {
|
||||||
relayDot.className = `dot ${dotClass(sel.rtt)}`;
|
const ls = lockStatus(sel);
|
||||||
|
relayDot.textContent = lockIcon(ls);
|
||||||
|
relayDot.className = "relay-lock";
|
||||||
relayLabel.textContent = `${sel.name} (${sel.address})`;
|
relayLabel.textContent = `${sel.name} (${sel.address})`;
|
||||||
} else {
|
} else {
|
||||||
relayDot.className = "dot gray";
|
relayDot.textContent = "⚪";
|
||||||
|
relayDot.className = "relay-lock";
|
||||||
relayLabel.textContent = "No relay configured";
|
relayLabel.textContent = "No relay configured";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
relaySelected.addEventListener("click", () => openRelayDialog());
|
relaySelected.addEventListener("click", () => openRelayDialog());
|
||||||
|
|
||||||
// ── Relay manage dialog ──
|
// ── Relay dialog ──
|
||||||
function openRelayDialog() {
|
function openRelayDialog() {
|
||||||
renderRelayDialogList();
|
renderRelayDialogList();
|
||||||
relayAddName.value = "";
|
relayAddName.value = "";
|
||||||
@@ -169,43 +193,73 @@ function closeRelayDialog() {
|
|||||||
|
|
||||||
function renderRelayDialogList() {
|
function renderRelayDialogList() {
|
||||||
const s = loadSettings();
|
const s = loadSettings();
|
||||||
relayDialogList.innerHTML = s.relays
|
relayDialogList.innerHTML = "";
|
||||||
.map((r, i) => `
|
s.relays.forEach((r, i) => {
|
||||||
<div class="relay-dialog-item ${i === s.selectedRelay ? "selected" : ""}" data-idx="${i}">
|
const item = document.createElement("div");
|
||||||
<span class="dot ${dotClass(r.rtt)}"></span>
|
item.className = `relay-dialog-item ${i === s.selectedRelay ? "selected" : ""}`;
|
||||||
<div class="relay-info">
|
|
||||||
|
const ls = lockStatus(r);
|
||||||
|
const fp = r.serverFingerprint || r.address;
|
||||||
|
|
||||||
|
// Identicon
|
||||||
|
const icon = createIdenticonEl(fp, 32, true);
|
||||||
|
icon.title = r.serverFingerprint
|
||||||
|
? `Server: ${r.serverFingerprint}\nClick to copy`
|
||||||
|
: `No fingerprint yet`;
|
||||||
|
item.appendChild(icon);
|
||||||
|
|
||||||
|
// Info
|
||||||
|
const info = document.createElement("div");
|
||||||
|
info.className = "relay-info";
|
||||||
|
info.innerHTML = `
|
||||||
<div class="relay-name">${escapeHtml(r.name)}</div>
|
<div class="relay-name">${escapeHtml(r.name)}</div>
|
||||||
<div class="relay-addr">${escapeHtml(r.address)}</div>
|
<div class="relay-addr">${escapeHtml(r.address)}</div>
|
||||||
</div>
|
`;
|
||||||
<span class="relay-rtt">${rttText(r.rtt)}</span>
|
item.appendChild(info);
|
||||||
<button class="remove" data-idx="${i}">×</button>
|
|
||||||
</div>`)
|
|
||||||
.join("");
|
|
||||||
|
|
||||||
// Click item to select
|
// Lock + RTT
|
||||||
relayDialogList.querySelectorAll(".relay-dialog-item").forEach((el) => {
|
const meta = document.createElement("div");
|
||||||
el.addEventListener("click", () => {
|
meta.className = "relay-meta";
|
||||||
const idx = parseInt((el as HTMLElement).dataset.idx || "0");
|
const rttStr = r.rtt !== undefined && r.rtt !== null
|
||||||
const s = loadSettings();
|
? (r.rtt < 0 ? "offline" : `${r.rtt}ms`)
|
||||||
s.selectedRelay = idx;
|
: "";
|
||||||
saveSettingsObj(s);
|
meta.innerHTML = `
|
||||||
renderRelayDialogList();
|
<span class="relay-lock-icon" style="color:${lockColor(ls)}">${lockIcon(ls)}</span>
|
||||||
renderRelayButton();
|
<span class="relay-rtt">${rttStr}</span>
|
||||||
});
|
`;
|
||||||
});
|
item.appendChild(meta);
|
||||||
|
|
||||||
// Click × to delete
|
// Delete button
|
||||||
relayDialogList.querySelectorAll(".remove").forEach((btn) => {
|
const del = document.createElement("button");
|
||||||
btn.addEventListener("click", (e) => {
|
del.className = "remove";
|
||||||
|
del.textContent = "×";
|
||||||
|
del.addEventListener("click", (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const idx = parseInt((btn as HTMLElement).dataset.idx || "0");
|
|
||||||
const s = loadSettings();
|
const s = loadSettings();
|
||||||
s.relays.splice(idx, 1);
|
s.relays.splice(i, 1);
|
||||||
if (s.selectedRelay >= s.relays.length) s.selectedRelay = Math.max(0, s.relays.length - 1);
|
if (s.selectedRelay >= s.relays.length) s.selectedRelay = Math.max(0, s.relays.length - 1);
|
||||||
saveSettingsObj(s);
|
saveSettingsObj(s);
|
||||||
renderRelayDialogList();
|
renderRelayDialogList();
|
||||||
renderRelayButton();
|
renderRelayButton();
|
||||||
});
|
});
|
||||||
|
item.appendChild(del);
|
||||||
|
|
||||||
|
// Click to select
|
||||||
|
item.addEventListener("click", () => {
|
||||||
|
const s = loadSettings();
|
||||||
|
s.selectedRelay = i;
|
||||||
|
|
||||||
|
// TOFU: if first time seeing this server, trust its fingerprint
|
||||||
|
if (r.serverFingerprint && !r.knownFingerprint) {
|
||||||
|
s.relays[i].knownFingerprint = r.serverFingerprint;
|
||||||
|
}
|
||||||
|
|
||||||
|
saveSettingsObj(s);
|
||||||
|
renderRelayDialogList();
|
||||||
|
renderRelayButton();
|
||||||
|
});
|
||||||
|
|
||||||
|
relayDialogList.appendChild(item);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,24 +279,29 @@ relayAddBtn.addEventListener("click", () => {
|
|||||||
relayDialogClose.addEventListener("click", closeRelayDialog);
|
relayDialogClose.addEventListener("click", closeRelayDialog);
|
||||||
relayDialog.addEventListener("click", (e) => { if (e.target === relayDialog) closeRelayDialog(); });
|
relayDialog.addEventListener("click", (e) => { if (e.target === relayDialog) closeRelayDialog(); });
|
||||||
|
|
||||||
// ── Ping all relays ──
|
// ── Ping ──
|
||||||
|
interface PingResult { rtt_ms: number; server_fingerprint: string; }
|
||||||
|
|
||||||
async function pingAllRelays() {
|
async function pingAllRelays() {
|
||||||
const s = loadSettings();
|
const s = loadSettings();
|
||||||
for (let i = 0; i < s.relays.length; i++) {
|
for (let i = 0; i < s.relays.length; i++) {
|
||||||
const r = s.relays[i];
|
const r = s.relays[i];
|
||||||
try {
|
try {
|
||||||
const rtt: number = await invoke("ping_relay", { relay: r.address });
|
const result: PingResult = await invoke("ping_relay", { relay: r.address });
|
||||||
r.rtt = rtt;
|
r.rtt = result.rtt_ms;
|
||||||
|
r.serverFingerprint = result.server_fingerprint;
|
||||||
|
|
||||||
|
// TOFU: auto-save fingerprint on first contact
|
||||||
|
if (!r.knownFingerprint) {
|
||||||
|
r.knownFingerprint = result.server_fingerprint;
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
r.rtt = -1;
|
r.rtt = -1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
saveSettingsObj(s);
|
saveSettingsObj(s);
|
||||||
renderRelayButton();
|
renderRelayButton();
|
||||||
// Also update dialog if open
|
if (!relayDialog.classList.contains("hidden")) renderRelayDialogList();
|
||||||
if (!relayDialog.classList.contains("hidden")) {
|
|
||||||
renderRelayDialogList();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Recent rooms ──
|
// ── Recent rooms ──
|
||||||
@@ -254,14 +313,9 @@ function renderRecentRooms(rooms: RecentRoom[]) {
|
|||||||
el.addEventListener("click", () => {
|
el.addEventListener("click", () => {
|
||||||
const ds = (el as HTMLElement).dataset;
|
const ds = (el as HTMLElement).dataset;
|
||||||
roomInput.value = ds.room || "";
|
roomInput.value = ds.room || "";
|
||||||
// Select matching relay
|
|
||||||
const s = loadSettings();
|
const s = loadSettings();
|
||||||
const idx = s.relays.findIndex((r) => r.address === ds.relay);
|
const idx = s.relays.findIndex((r) => r.address === ds.relay);
|
||||||
if (idx >= 0) {
|
if (idx >= 0) { s.selectedRelay = idx; saveSettingsObj(s); renderRelayButton(); }
|
||||||
s.selectedRelay = idx;
|
|
||||||
saveSettingsObj(s);
|
|
||||||
renderRelayButton();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -270,30 +324,28 @@ function renderRecentRooms(rooms: RecentRoom[]) {
|
|||||||
applySettings();
|
applySettings();
|
||||||
setTimeout(pingAllRelays, 300);
|
setTimeout(pingAllRelays, 300);
|
||||||
|
|
||||||
// Load fingerprint at startup
|
// Load fingerprint + render identicon
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const fp: string = await invoke("get_identity");
|
const fp: string = await invoke("get_identity");
|
||||||
myFingerprint = fp;
|
myFingerprint = fp;
|
||||||
myFingerprintEl.textContent = `ID: ${fp}`;
|
myFingerprintEl.textContent = fp;
|
||||||
|
myFingerprintEl.style.cursor = "pointer";
|
||||||
|
myFingerprintEl.addEventListener("click", () => {
|
||||||
|
navigator.clipboard.writeText(fp).then(() => {
|
||||||
|
const orig = myFingerprintEl.textContent;
|
||||||
|
myFingerprintEl.textContent = "Copied!";
|
||||||
|
setTimeout(() => { myFingerprintEl.textContent = orig; }, 1000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Identicon next to fingerprint
|
||||||
|
const icon = createIdenticonEl(fp, 28, true);
|
||||||
|
myIdenticonEl.innerHTML = "";
|
||||||
|
myIdenticonEl.appendChild(icon);
|
||||||
} catch {}
|
} catch {}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// Click fingerprint to copy
|
|
||||||
function copyFingerprint(el: HTMLElement) {
|
|
||||||
if (myFingerprint) {
|
|
||||||
navigator.clipboard.writeText(myFingerprint).then(() => {
|
|
||||||
const orig = el.textContent;
|
|
||||||
el.textContent = "Copied!";
|
|
||||||
setTimeout(() => { el.textContent = orig; }, 1000);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
myFingerprintEl.addEventListener("click", () => copyFingerprint(myFingerprintEl));
|
|
||||||
myFingerprintEl.style.cursor = "pointer";
|
|
||||||
sFingerprint.addEventListener("click", () => copyFingerprint(sFingerprint));
|
|
||||||
sFingerprint.style.cursor = "pointer";
|
|
||||||
|
|
||||||
// ── Connect ──
|
// ── Connect ──
|
||||||
connectBtn.addEventListener("click", doConnect);
|
connectBtn.addEventListener("click", doConnect);
|
||||||
[roomInput, aliasInput].forEach((el) =>
|
[roomInput, aliasInput].forEach((el) =>
|
||||||
@@ -302,24 +354,29 @@ connectBtn.addEventListener("click", doConnect);
|
|||||||
|
|
||||||
async function doConnect() {
|
async function doConnect() {
|
||||||
const relay = getSelectedRelay();
|
const relay = getSelectedRelay();
|
||||||
if (!relay) {
|
if (!relay) { connectError.textContent = "No relay selected"; return; }
|
||||||
connectError.textContent = "No relay selected";
|
|
||||||
|
// Warn on fingerprint mismatch
|
||||||
|
const ls = lockStatus(relay);
|
||||||
|
if (ls === "changed") {
|
||||||
|
if (!confirm(`Server fingerprint has changed!\n\nKnown: ${relay.knownFingerprint}\nNew: ${relay.serverFingerprint}\n\nThis could indicate a man-in-the-middle attack. Continue?`)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (relay.rtt !== undefined && relay.rtt !== null && relay.rtt < 0) {
|
// User accepted — update known fingerprint
|
||||||
connectError.textContent = "Relay is offline";
|
const s = loadSettings();
|
||||||
return;
|
s.relays[s.selectedRelay].knownFingerprint = relay.serverFingerprint;
|
||||||
|
saveSettingsObj(s);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (ls === "offline") { connectError.textContent = "Relay is offline"; return; }
|
||||||
|
|
||||||
connectError.textContent = "";
|
connectError.textContent = "";
|
||||||
connectBtn.disabled = true;
|
connectBtn.disabled = true;
|
||||||
connectBtn.textContent = "Connecting...";
|
connectBtn.textContent = "Connecting...";
|
||||||
userDisconnected = false;
|
userDisconnected = false;
|
||||||
|
|
||||||
// Save recent room
|
|
||||||
const s = loadSettings();
|
const s = loadSettings();
|
||||||
s.room = roomInput.value;
|
s.room = roomInput.value; s.alias = aliasInput.value; s.osAec = osAecCheckbox.checked;
|
||||||
s.alias = aliasInput.value;
|
|
||||||
s.osAec = osAecCheckbox.checked;
|
|
||||||
const room = roomInput.value.trim();
|
const room = roomInput.value.trim();
|
||||||
if (room) {
|
if (room) {
|
||||||
const entry: RecentRoom = { relay: relay.address, room };
|
const entry: RecentRoom = { relay: relay.address, room };
|
||||||
@@ -329,10 +386,8 @@ async function doConnect() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await invoke("connect", {
|
await invoke("connect", {
|
||||||
relay: relay.address,
|
relay: relay.address, room: roomInput.value,
|
||||||
room: roomInput.value,
|
alias: aliasInput.value, osAec: osAecCheckbox.checked,
|
||||||
alias: aliasInput.value,
|
|
||||||
osAec: osAecCheckbox.checked,
|
|
||||||
});
|
});
|
||||||
showCallScreen();
|
showCallScreen();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@@ -361,21 +416,11 @@ function showConnectScreen() {
|
|||||||
|
|
||||||
// ── Mute / hangup ──
|
// ── Mute / hangup ──
|
||||||
micBtn.addEventListener("click", async () => {
|
micBtn.addEventListener("click", async () => {
|
||||||
try {
|
try { const m: boolean = await invoke("toggle_mic"); micBtn.classList.toggle("muted", m); micIcon.textContent = m ? "Mic Off" : "Mic"; } catch {}
|
||||||
const muted: boolean = await invoke("toggle_mic");
|
|
||||||
micBtn.classList.toggle("muted", muted);
|
|
||||||
micIcon.textContent = muted ? "Mic Off" : "Mic";
|
|
||||||
} catch {}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
spkBtn.addEventListener("click", async () => {
|
spkBtn.addEventListener("click", async () => {
|
||||||
try {
|
try { const m: boolean = await invoke("toggle_speaker"); spkBtn.classList.toggle("muted", m); spkIcon.textContent = m ? "Spk Off" : "Spk"; } catch {}
|
||||||
const muted: boolean = await invoke("toggle_speaker");
|
|
||||||
spkBtn.classList.toggle("muted", muted);
|
|
||||||
spkIcon.textContent = muted ? "Spk Off" : "Spk";
|
|
||||||
} catch {}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
hangupBtn.addEventListener("click", async () => {
|
hangupBtn.addEventListener("click", async () => {
|
||||||
userDisconnected = true;
|
userDisconnected = true;
|
||||||
try { await invoke("disconnect"); } catch {}
|
try { await invoke("disconnect"); } catch {}
|
||||||
@@ -392,15 +437,10 @@ document.addEventListener("keydown", (e) => {
|
|||||||
|
|
||||||
// ── Status polling ──
|
// ── Status polling ──
|
||||||
interface CallStatusI {
|
interface CallStatusI {
|
||||||
active: boolean;
|
active: boolean; mic_muted: boolean; spk_muted: boolean;
|
||||||
mic_muted: boolean;
|
|
||||||
spk_muted: boolean;
|
|
||||||
participants: { fingerprint: string; alias: string | null }[];
|
participants: { fingerprint: string; alias: string | null }[];
|
||||||
encode_fps: number;
|
encode_fps: number; recv_fps: number; audio_level: number;
|
||||||
recv_fps: number;
|
call_duration_secs: number; fingerprint: string;
|
||||||
audio_level: number;
|
|
||||||
call_duration_secs: number;
|
|
||||||
fingerprint: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDuration(secs: number): string {
|
function formatDuration(secs: number): string {
|
||||||
@@ -410,35 +450,28 @@ function formatDuration(secs: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let reconnectAttempts = 0;
|
let reconnectAttempts = 0;
|
||||||
const MAX_RECONNECT = 5;
|
|
||||||
|
|
||||||
async function pollStatus() {
|
async function pollStatus() {
|
||||||
try {
|
try {
|
||||||
const st: CallStatusI = await invoke("get_status");
|
const st: CallStatusI = await invoke("get_status");
|
||||||
if (!st.active) {
|
if (!st.active) {
|
||||||
if (!userDisconnected && reconnectAttempts < MAX_RECONNECT) {
|
if (!userDisconnected && reconnectAttempts < 5) {
|
||||||
reconnectAttempts++;
|
reconnectAttempts++;
|
||||||
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts - 1), 10000);
|
|
||||||
callStatus.className = "status-dot reconnecting";
|
callStatus.className = "status-dot reconnecting";
|
||||||
statsDiv.textContent = `Reconnecting (${reconnectAttempts}/${MAX_RECONNECT})...`;
|
statsDiv.textContent = `Reconnecting (${reconnectAttempts}/5)...`;
|
||||||
const relay = getSelectedRelay();
|
const relay = getSelectedRelay();
|
||||||
if (relay) {
|
if (relay) {
|
||||||
|
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts - 1), 10000);
|
||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
try {
|
try {
|
||||||
await invoke("connect", {
|
await invoke("connect", { relay: relay.address, room: roomInput.value, alias: aliasInput.value, osAec: osAecCheckbox.checked });
|
||||||
relay: relay.address, room: roomInput.value,
|
reconnectAttempts = 0; callStatus.className = "status-dot";
|
||||||
alias: aliasInput.value, osAec: osAecCheckbox.checked,
|
|
||||||
});
|
|
||||||
reconnectAttempts = 0;
|
|
||||||
callStatus.className = "status-dot";
|
|
||||||
} catch {}
|
} catch {}
|
||||||
}, delay);
|
}, delay);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
reconnectAttempts = 0;
|
reconnectAttempts = 0; showConnectScreen(); return;
|
||||||
showConnectScreen();
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
reconnectAttempts = 0;
|
reconnectAttempts = 0;
|
||||||
@@ -448,59 +481,59 @@ async function pollStatus() {
|
|||||||
micIcon.textContent = st.mic_muted ? "Mic Off" : "Mic";
|
micIcon.textContent = st.mic_muted ? "Mic Off" : "Mic";
|
||||||
spkBtn.classList.toggle("muted", st.spk_muted);
|
spkBtn.classList.toggle("muted", st.spk_muted);
|
||||||
spkIcon.textContent = st.spk_muted ? "Spk Off" : "Spk";
|
spkIcon.textContent = st.spk_muted ? "Spk Off" : "Spk";
|
||||||
|
|
||||||
callTimer.textContent = formatDuration(st.call_duration_secs);
|
callTimer.textContent = formatDuration(st.call_duration_secs);
|
||||||
|
|
||||||
const rms = st.audio_level;
|
const rms = st.audio_level;
|
||||||
const pct = rms > 0 ? Math.min(100, (Math.log(rms) / Math.log(32767)) * 100) : 0;
|
const pct = rms > 0 ? Math.min(100, (Math.log(rms) / Math.log(32767)) * 100) : 0;
|
||||||
levelBar.style.width = `${pct}%`;
|
levelBar.style.width = `${pct}%`;
|
||||||
|
|
||||||
|
// Participants with identicons
|
||||||
if (st.participants.length === 0) {
|
if (st.participants.length === 0) {
|
||||||
participantsDiv.innerHTML = '<div class="participants-empty">Waiting for participants...</div>';
|
participantsDiv.innerHTML = '<div class="participants-empty">Waiting for participants...</div>';
|
||||||
} else {
|
} else {
|
||||||
participantsDiv.innerHTML = st.participants.map((p) => {
|
participantsDiv.innerHTML = "";
|
||||||
|
st.participants.forEach((p) => {
|
||||||
const name = p.alias || "Anonymous";
|
const name = p.alias || "Anonymous";
|
||||||
const initial = name.charAt(0).toUpperCase();
|
const fp = p.fingerprint || "";
|
||||||
const fp = p.fingerprint ? p.fingerprint.substring(0, 16) : "";
|
const isMe = fp && myFingerprint.includes(fp);
|
||||||
const isMe = p.fingerprint && myFingerprint.includes(p.fingerprint);
|
|
||||||
return `
|
const row = document.createElement("div");
|
||||||
<div class="participant">
|
row.className = "participant";
|
||||||
<div class="avatar ${isMe ? "me" : ""}">${initial}</div>
|
|
||||||
<div class="info">
|
// Identicon avatar
|
||||||
|
const icon = createIdenticonEl(fp || name, 36, true);
|
||||||
|
if (isMe) icon.style.outline = "2px solid var(--accent)";
|
||||||
|
row.appendChild(icon);
|
||||||
|
|
||||||
|
const info = document.createElement("div");
|
||||||
|
info.className = "info";
|
||||||
|
info.innerHTML = `
|
||||||
<div class="name">${escapeHtml(name)} ${isMe ? '<span class="you-badge">you</span>' : ""}</div>
|
<div class="name">${escapeHtml(name)} ${isMe ? '<span class="you-badge">you</span>' : ""}</div>
|
||||||
<div class="fp">${escapeHtml(fp)}</div>
|
<div class="fp">${escapeHtml(fp ? fp.substring(0, 16) : "")}</div>
|
||||||
</div>
|
`;
|
||||||
</div>`;
|
row.appendChild(info);
|
||||||
}).join("");
|
participantsDiv.appendChild(row);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
statsDiv.textContent = `TX: ${st.encode_fps} | RX: ${st.recv_fps}`;
|
statsDiv.textContent = `TX: ${st.encode_fps} | RX: ${st.recv_fps}`;
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeHtml(s: string): string {
|
|
||||||
const d = document.createElement("div");
|
|
||||||
d.textContent = s;
|
|
||||||
return d.innerHTML;
|
|
||||||
}
|
|
||||||
|
|
||||||
listen("call-event", (event: any) => {
|
listen("call-event", (event: any) => {
|
||||||
const { kind } = event.payload;
|
const { kind } = event.payload;
|
||||||
if (kind === "room-update") pollStatus();
|
if (kind === "room-update") pollStatus();
|
||||||
if (kind === "disconnected" && !userDisconnected) pollStatus();
|
if (kind === "disconnected" && !userDisconnected) pollStatus();
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Settings panel ──
|
// ── Settings ──
|
||||||
function openSettings() {
|
function openSettings() {
|
||||||
const s = loadSettings();
|
const s = loadSettings();
|
||||||
sRoom.value = s.room;
|
sRoom.value = s.room; sAlias.value = s.alias; sOsAec.checked = s.osAec;
|
||||||
sAlias.value = s.alias;
|
|
||||||
sOsAec.checked = s.osAec;
|
|
||||||
sFingerprint.textContent = myFingerprint || "(loading...)";
|
sFingerprint.textContent = myFingerprint || "(loading...)";
|
||||||
renderSettingsRecentRooms(s.recentRooms);
|
renderSettingsRecentRooms(s.recentRooms);
|
||||||
settingsPanel.classList.remove("hidden");
|
settingsPanel.classList.remove("hidden");
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeSettings() { settingsPanel.classList.add("hidden"); }
|
function closeSettings() { settingsPanel.classList.add("hidden"); }
|
||||||
|
|
||||||
function renderSettingsRecentRooms(rooms: RecentRoom[]) {
|
function renderSettingsRecentRooms(rooms: RecentRoom[]) {
|
||||||
@@ -511,7 +544,7 @@ function renderSettingsRecentRooms(rooms: RecentRoom[]) {
|
|||||||
sRecentRooms.innerHTML = rooms.map((r, i) => `
|
sRecentRooms.innerHTML = rooms.map((r, i) => `
|
||||||
<div class="recent-room-item">
|
<div class="recent-room-item">
|
||||||
<span>${escapeHtml(r.room)} <small style="color:var(--text-dim)">${escapeHtml(r.relay)}</small></span>
|
<span>${escapeHtml(r.room)} <small style="color:var(--text-dim)">${escapeHtml(r.relay)}</small></span>
|
||||||
<button class="remove" data-idx="${i}">×</button>
|
<button class="remove" data-idx="${i}">×</button>
|
||||||
</div>`).join("");
|
</div>`).join("");
|
||||||
sRecentRooms.querySelectorAll(".remove").forEach((btn) => {
|
sRecentRooms.querySelectorAll(".remove").forEach((btn) => {
|
||||||
btn.addEventListener("click", () => {
|
btn.addEventListener("click", () => {
|
||||||
@@ -531,13 +564,9 @@ settingsPanel.addEventListener("click", (e) => { if (e.target === settingsPanel)
|
|||||||
|
|
||||||
settingsSave.addEventListener("click", () => {
|
settingsSave.addEventListener("click", () => {
|
||||||
const s = loadSettings();
|
const s = loadSettings();
|
||||||
s.room = sRoom.value;
|
s.room = sRoom.value; s.alias = sAlias.value; s.osAec = sOsAec.checked;
|
||||||
s.alias = sAlias.value;
|
|
||||||
s.osAec = sOsAec.checked;
|
|
||||||
saveSettingsObj(s);
|
saveSettingsObj(s);
|
||||||
roomInput.value = s.room;
|
roomInput.value = s.room; aliasInput.value = s.alias; osAecCheckbox.checked = s.osAec;
|
||||||
aliasInput.value = s.alias;
|
|
||||||
osAecCheckbox.checked = s.osAec;
|
|
||||||
renderRecentRooms(s.recentRooms);
|
renderRecentRooms(s.recentRooms);
|
||||||
closeSettings();
|
closeSettings();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -108,10 +108,8 @@ body {
|
|||||||
|
|
||||||
.relay-selected:hover { border-color: var(--accent); }
|
.relay-selected:hover { border-color: var(--accent); }
|
||||||
|
|
||||||
.relay-selected .dot {
|
.relay-lock {
|
||||||
width: 8px;
|
font-size: 14px;
|
||||||
height: 8px;
|
|
||||||
border-radius: 50%;
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,6 +169,17 @@ body {
|
|||||||
.relay-dialog-item .relay-addr { font-size: 11px; color: var(--text-dim); font-family: monospace; overflow: hidden; text-overflow: ellipsis; }
|
.relay-dialog-item .relay-addr { font-size: 11px; color: var(--text-dim); font-family: monospace; overflow: hidden; text-overflow: ellipsis; }
|
||||||
.relay-dialog-item .relay-rtt { font-size: 11px; color: var(--text-dim); margin-right: 4px; }
|
.relay-dialog-item .relay-rtt { font-size: 11px; color: var(--text-dim); margin-right: 4px; }
|
||||||
|
|
||||||
|
.relay-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.relay-lock-icon { font-size: 16px; }
|
||||||
|
.relay-meta .relay-rtt { font-size: 10px; color: var(--text-dim); }
|
||||||
|
|
||||||
.relay-dialog-item .remove {
|
.relay-dialog-item .remove {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
@@ -254,7 +263,10 @@ button.primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
|||||||
}
|
}
|
||||||
|
|
||||||
.identity-info {
|
.identity-info {
|
||||||
text-align: center;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fp-display {
|
.fp-display {
|
||||||
|
|||||||
Reference in New Issue
Block a user