Merge branch 'main' into gossip-graph

This commit is contained in:
callebtc
2025-09-08 13:49:32 +02:00
31 changed files with 2356 additions and 2466 deletions
+6
View File
@@ -41,6 +41,12 @@
android:supportsRtl="true"
android:theme="@style/Theme.BitchatAndroid"
tools:targetApi="31">
<activity
android:name=".ui.GeohashPickerActivity"
android:exported="false"
android:theme="@style/Theme.BitchatAndroid"
android:screenOrientation="portrait"
android:windowSoftInputMode="adjustResize" />
<activity
android:name=".MainActivity"
android:exported="true"
+225
View File
@@ -0,0 +1,225 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style>
:root { --text: #333; }
html, body, #map { height: 100%; margin: 0; padding: 0; background: #ffffff; }
.leaflet-container { background: #ffffff; }
.leaflet-div-icon { background: transparent; border: none; }
.gh-label { background: transparent; border: none; pointer-events: none; filter: none; }
.gh-text {
color: #444444;
font-weight: 700;
font-size: 14px;
line-height: 1;
text-shadow: 0 0 2px #ffffff, 0 0 2px #ffffff, 0 0 2px #ffffff;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
}
.dark .gh-text {
color: #dddddd;
text-shadow: 0 0 2px #000000, 0 0 2px #000000, 0 0 2px #000000;
}
.gh-text-selected {
color: #00C851 !important;
}
</style>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
</head>
<body>
<div id="map"></div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script>
// Minimal geohash (bounds/encode/adjacent)
(function () {
const base32 = "0123456789bcdefghjkmnpqrstuvwxyz";
function bounds(geohash) {
let evenBit = true; let latMin = -90, latMax = 90, lonMin = -180, lonMax = 180;
geohash = geohash.toLowerCase();
for (let i = 0; i < geohash.length; i++) {
const idx = base32.indexOf(geohash.charAt(i));
if (idx == -1) throw new Error("Invalid geohash");
for (let n = 4; n >= 0; n--) {
const bitN = (idx >> n) & 1;
if (evenBit) { const lonMid = (lonMin + lonMax) / 2; if (bitN == 1) lonMin = lonMid; else lonMax = lonMid; }
else { const latMid = (latMin + latMax) / 2; if (bitN == 1) latMin = latMid; else latMax = latMid; }
evenBit = !evenBit;
}
}
return { sw: { lat: latMin, lng: lonMin }, ne: { lat: latMax, lng: lonMax } };
}
function encode(lat, lon, precision) {
let idx = 0, bit = 0, evenBit = true, hash = "";
let latMin = -90, latMax = 90, lonMin = -180, lonMax = 180;
while (hash.length < precision) {
if (evenBit) { const lonMid = (lonMin + lonMax) / 2; if (lon >= lonMid) { idx = idx * 2 + 1; lonMin = lonMid; } else { idx = idx * 2; lonMax = lonMid; } }
else { const latMid = (latMin + latMax) / 2; if (lat >= latMid) { idx = idx * 2 + 1; latMin = latMid; } else { idx = idx * 2; latMax = latMid; } }
evenBit = !evenBit; if (++bit == 5) { hash += base32.charAt(idx); bit = 0; idx = 0; }
}
return hash;
}
function adjacent(hash, dir) {
const neighbour = { n:["p0r21436x8zb9dcf5h7kjnmqesgutwvy","bc01fg45238967deuvhjyznpkmstqrwx"], s:["14365h7k9dcfesgujnmqp0r2twvyx8zb","238967debc01fg45kmstqrwxuvhjyznp"], e:["bc01fg45238967deuvhjyznpkmstqrwx","p0r21436x8zb9dcf5h7kjnmqesgutwvy"], w:["238967debc01fg45kmstqrwxuvhjyznp","14365h7k9dcfesgujnmqp0r2twvyx8zb"] };
const border = { n:["prxz","bcfguvyz"], s:["028b","0145hjnp"], e:["bcfguvyz","prxz"], w:["0145hjnp","028b"] };
hash = hash.toLowerCase(); const lastCh = hash.slice(-1); let parent = hash.slice(0, -1); const type = hash.length % 2;
if (border[dir][type].indexOf(lastCh) != -1 && parent != "") parent = adjacent(parent, dir);
return parent + base32.charAt(neighbour[dir][type].indexOf(lastCh));
}
window.__geohash = { bounds, encode, adjacent };
})();
const map = L.map("map", { zoomControl: true, minZoom: 2, maxZoom: 21 }).setView([0, 0], 3);
L.tileLayer("https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png", { maxZoom: 21, attribution: "&copy; OpenStreetMap &copy; Carto", opacity: 1.0 }).addTo(map);
let selectedGeohash = "";
let gridLayer = L.layerGroup().addTo(map);
let pinnedPrecision = null;
let outlineColor = "#00C851";
function getNeighbors(hash) {
const neighbors = [];
// N, S, E, W
neighbors.push(window.__geohash.adjacent(hash, 'n'));
neighbors.push(window.__geohash.adjacent(hash, 's'));
neighbors.push(window.__geohash.adjacent(hash, 'e'));
neighbors.push(window.__geohash.adjacent(hash, 'w'));
// Diagonals
neighbors.push(window.__geohash.adjacent(window.__geohash.adjacent(hash, 'n'), 'e'));
neighbors.push(window.__geohash.adjacent(window.__geohash.adjacent(hash, 'n'), 'w'));
neighbors.push(window.__geohash.adjacent(window.__geohash.adjacent(hash, 's'), 'e'));
neighbors.push(window.__geohash.adjacent(window.__geohash.adjacent(hash, 's'), 'w'));
return neighbors;
}
function pickPrecisionForViewport() {
const c = map.getCenter();
const minPx = 80;
const maxPx = 240;
let chosen = 1;
let lastAboveMin = 1;
for (let p = 1; p <= 12; p++) {
const gh = window.__geohash.encode(c.lat, c.lng, p);
const b = window.__geohash.bounds(gh);
const pSw = map.latLngToLayerPoint([b.sw.lat, b.sw.lng]);
const pNe = map.latLngToLayerPoint([b.ne.lat, b.ne.lng]);
const cellPx = Math.min(Math.abs(pNe.x - pSw.x), Math.abs(pSw.y - pNe.y));
if (cellPx >= minPx && cellPx <= maxPx) { chosen = p; break; }
if (cellPx >= minPx) { lastAboveMin = p; }
if (cellPx < minPx) { chosen = lastAboveMin; break; }
if (p === 12) { chosen = 12; }
}
return chosen;
}
function notifySelection() {
if (window.Android && window.Android.onGeohashChanged && selectedGeohash) {
window.Android.onGeohashChanged(selectedGeohash);
}
}
function zoomForPrecision(p) {
if (p <= 1) return 1; if (p === 2) return 2; if (p === 3) return 3; if (p === 4) return 4;
if (p === 5) return 5; if (p === 6) return 7; if (p === 7) return 9; if (p === 8) return 11;
if (p === 9) return 13; if (p === 10) return 15; if (p === 11) return 17;
return 18;
}
function updateOverlay() {
gridLayer.clearLayers();
const c = map.getCenter();
const usePinned = pinnedPrecision !== null;
const p = usePinned ? pinnedPrecision : pickPrecisionForViewport();
selectedGeohash = window.__geohash.encode(c.lat, c.lng, p);
notifySelection();
const centerBounds = window.__geohash.bounds(selectedGeohash);
const centerLon = (centerBounds.sw.lng + centerBounds.ne.lng) / 2;
const centerLat = (centerBounds.sw.lat + centerBounds.ne.lat) / 2;
const allHashes = [selectedGeohash, ...getNeighbors(selectedGeohash)];
const filteredHashes = allHashes.filter(gh => {
if (!gh) return false;
try {
const b = window.__geohash.bounds(gh);
const lon = (b.sw.lng + b.ne.lng) / 2;
const lat = (b.sw.lat + b.ne.lat) / 2;
if (Math.abs(lon - centerLon) > 180) return false; // anti-meridian wrap
if (Math.abs(lat - centerLat) > 90) return false; // pole wrap
return true;
} catch (e) { return false; }
});
filteredHashes.forEach(gh => {
const b = window.__geohash.bounds(gh);
const sw = [b.sw.lat, b.sw.lng];
const ne = [b.ne.lat, b.ne.lng];
const isSelected = (gh === selectedGeohash);
const rect = L.rectangle([sw, ne], {
color: isSelected ? outlineColor : '#cccccc',
weight: isSelected ? 3 : 1,
fillOpacity: 0.0,
opacity: 0.9,
interactive: false
});
gridLayer.addLayer(rect);
const center = [(b.sw.lat + b.ne.lat) / 2, (b.sw.lng + b.ne.lng) / 2];
const labelClass = isSelected ? 'gh-text gh-text-selected' : 'gh-text';
const label = L.marker(center, {
icon: L.divIcon({
className: 'gh-label',
html: `<span class="${labelClass}">${gh}</span>`
}),
interactive: false
});
gridLayer.addLayer(label);
});
}
map.on("movestart", () => { pinnedPrecision = null; });
map.on("zoomstart", () => { pinnedPrecision = null; });
map.on("moveend", updateOverlay);
map.on("zoomend", updateOverlay);
function setCenter(lat, lng) { map.setView([lat, lng], map.getZoom()); }
function setPrecision(p) {
const clamped = Math.max(1, Math.min(12, p|0));
const targetZoom = zoomForPrecision(clamped);
map.setZoom(targetZoom);
}
function focusGeohash(gh) {
if (!gh || typeof gh !== 'string') return;
const g = gh.toLowerCase();
const b = window.__geohash.bounds(g);
pinnedPrecision = g.length;
map.fitBounds([[b.sw.lat, b.sw.lng],[b.ne.lat, b.ne.lng]], { animate: false, padding: [8,8] });
selectedGeohash = g;
}
function getGeohash() { return selectedGeohash; }
// Android side will call this with 'dark' or 'light'
function setMapTheme(theme) {
document.body.className = theme;
}
window.setCenter = setCenter;
window.setPrecision = setPrecision;
window.focusGeohash = focusGeohash;
window.getGeohash = getGeohash;
window.setMapTheme = setMapTheme;
function cleanup() {
try { map.off(); } catch (_) {}
try { gridLayer.clearLayers(); } catch (_) {}
try { map.remove(); } catch (_) {}
}
window.cleanup = cleanup;
map.whenReady(updateOverlay);
</script>
</body>
</html>
+255 -265
View File
@@ -1,273 +1,263 @@
Relay URL,Latitude,Longitude
relay.chorus.community,50.1109,8.68213
relay-rpi.edufeed.org,49.4543,11.0746
nostr.chaima.info,50.1109,8.68213
relay.nostraddress.com,43.6532,-79.3832
nostr.middling.mydns.jp,35.8099,140.12
nostr.myshosholoza.co.za,52.3676,4.90414
n.ok0.org,-37.7254,176.133
relay.21e6.cz,50.1682,14.0546
premium.primal.net,43.6532,-79.3832
relay.stream.labs.h3.se,59.4016,17.9455
nostr.night7.space,50.4754,12.3683
nostr.4rs.nl,49.0291,8.35696
relay.notoshi.win,13.7683,100.54
nostr.carroarmato0.be,50.9017,3.52588
relay.angor.io,48.1046,11.6002
relay.nostriot.com,41.5695,-83.9786
nostr-pub.wellorder.net,45.5201,-122.99
relay04.lnfi.network,39.0997,-94.5786
x.kojira.io,43.6532,-79.3832
strfry.felixzieger.de,50.1013,8.62643
purpura.cloud,43.6532,-79.3832
alien.macneilmediagroup.com,43.6532,-79.3832
wot.sudocarlos.com,51.5072,-0.127586
relay02.lnfi.network,39.0997,-94.5786
relay.getsafebox.app,43.6532,-79.3832
wot.sebastix.social,51.8933,4.42083
relay.dwadziesciajeden.pl,52.2297,21.0122
nostr.roundrockbitcoiners.com,40.8054,-74.0241
nostr.dlsouza.lol,50.1109,8.68213
nostr-relay.shirogaku.xyz,43.6532,-79.3832
yabu.me,35.6092,139.73
nostr.stakey.net,52.3676,4.90414
relay.sincensura.org,43.6532,-79.3832
nostr-02.czas.top,51.2277,6.77346
soloco.nl,43.6532,-79.3832
nostream.breadslice.com,43.6532,-79.3832
nostr.camalolo.com,24.1469,120.684
nostr-dev.wellorder.net,45.5201,-122.99
nostr.pleb.one,38.6327,-90.1961
cyberspace.nostr1.com,40.7357,-74.1724
relay.illuminodes.com,47.6061,-122.333
satsage.xyz,37.3986,-121.964
nostr.data.haus,50.4754,12.3683
nostr.jfischer.org,49.0291,8.35696
r.bitcoinhold.net,43.6532,-79.3832
relay.barine.co,43.6532,-79.3832
wot.sovbit.host,64.1466,-21.9426
nostr.vulpem.com,49.4543,11.0746
nostr.kalf.org,52.3676,4.90414
nostr.thebiglake.org,32.71,-96.6745
relay.bitcoinveneto.org,64.1466,-21.9426
wot.dergigi.com,64.1476,-21.9392
nos.xmark.cc,50.6924,3.20113
nostr.88mph.life,43.6532,-79.3832
relay.mess.ch,47.3591,8.55292
nostr.thaliyal.com,40.8218,-74.45
nostr.notribe.net,40.8302,-74.1299
relay.orangepill.ovh,49.1689,-0.358841
relay.artiostr.ch,43.6532,-79.3832
relay.13room.space,43.6532,-79.3832
relay.primal.net,43.6532,-79.3832
strfry.shock.network,41.8959,-88.2169
nostr.2b9t.xyz,34.0549,-118.243
ribo.af.nostria.app,-26.2041,28.0473
a.nos.lol,50.4754,12.3683
relay.mccormick.cx,52.3563,4.95714
relay.mostro.network,40.8302,-74.1299
relay.bitcoinartclock.com,50.4754,12.3683
nostr-relay.cbrx.io,43.6532,-79.3832
prl.plus,55.7623,37.6381
relay.endfiat.money,43.6532,-79.3832
relay.hook.cafe,43.6532,-79.3832
relay.siamdev.cc,13.9178,100.424
wot.nostr.party,36.1627,-86.7816
wot.basspistol.org,49.4521,11.0767
dev-nostr.bityacht.io,25.0797,121.234
orangesync.tech,51.5072,-0.127586
nostr.casa21.space,43.6532,-79.3832
relay.digitalezukunft.cyou,45.5019,-73.5674
relay.nostrcheck.me,43.6532,-79.3832
inbox.mycelium.social,38.627,-90.1994
nostr.liberty.fans,36.9104,-89.5875
nostr-verified.wellorder.net,45.5201,-122.99
relay.sigit.io,50.4754,12.3683
tollbooth.stens.dev,51.223,6.78245
relay.nostromo.social,49.4543,11.0746
wheat.happytavern.co,43.6532,-79.3832
relay.vrtmrz.net,43.6532,-79.3832
relayrs.notoshi.win,43.6532,-79.3832
travis-shears-nostr-relay-v2.fly.dev,41.8781,-87.6298
nostr.community.ath.cx,45.5029,-73.5723
wot.girino.org,43.6532,-79.3832
nostr.coincrowd.fund,39.0438,-77.4874
relay.freeplace.nl,52.3676,4.90414
nostr-03.dorafactory.org,1.35208,103.82
relay5.bitransfer.org,43.6532,-79.3832
nostr.tac.lol,47.4748,-122.273
nostr.sagaciousd.com,49.2827,-123.121
nostr.girino.org,43.6532,-79.3832
nostr.jerrynya.fun,31.2304,121.474
relay.agorist.space,52.3734,4.89406
inbox.azzamo.net,52.2633,21.0283
wot.codingarena.top,50.4754,12.3683
relay.degmods.com,50.4754,12.3683
articles.layer3.news,37.3387,-121.885
nostr.rblb.it,43.4633,11.8796
relay.davidebtc.me,51.5072,-0.127586
relay.hodl.ar,-34.5904,-58.629
nostr.rtvslawenia.com,49.4543,11.0746
relay01.lnfi.network,39.0997,-94.5786
strfry.bonsai.com,37.8715,-122.273
relay.nostr.place,32.7767,-96.797
relay.bitcoindistrict.org,43.6532,-79.3832
relay.aloftus.io,34.0881,-118.379
nostr.massmux.com,51.5072,-0.127586
relay.wellorder.net,45.5201,-122.99
santo.iguanatech.net,40.8302,-74.1299
relay.damus.io,43.6532,-79.3832
relay.javi.space,43.4633,11.8796
relay.nosto.re,51.8933,4.42083
relay.utxo.farm,35.6916,139.768
relay.fr13nd5.com,52.5233,13.3426
librerelay.aaroniumii.com,43.6532,-79.3832
relay.cypherflow.ai,48.8566,2.35222
relay.letsfo.com,51.098,17.0321
nostr.0x7e.xyz,47.4988,8.72369
relay.bullishbounty.com,43.6532,-79.3832
relay.tagayasu.xyz,43.6715,-79.38
nostr.plantroon.com,50.1013,8.62643
strfry.openhoofd.nl,51.9229,4.40833
nostrelites.org,41.8781,-87.6298
relay.credenso.cafe,43.1149,-80.7228
relay.conduit.market,43.6532,-79.3832
relay.olas.app,50.4754,12.3683
relay.nostrdice.com,-33.8688,151.209
nostr.oxtr.dev,50.4754,12.3683
relay.nostr.band,60.1699,24.9384
khatru.nostrver.se,51.8933,4.42083
relay.coinos.io,43.6532,-79.3832
nostr.l484.com,30.2944,-97.6223
purplerelay.com,50.1109,8.68213
relay.nostrhub.fr,48.1046,11.6002
black.nostrcity.club,41.8781,-87.6298
nostr.snowbla.de,60.1699,24.9384
no.str.cr,9.93388,-84.0849
nostr.rikmeijer.nl,50.4754,12.3683
nproxy.kristapsk.lv,60.1699,24.9384
wot.soundhsa.com,34.0479,-118.256
nostr.kungfu-g.rip,33.7946,-84.4488
nostr.spaceshell.xyz,43.6532,-79.3832
nostr.mikoshi.de,50.1109,8.68213
gnostr.com,40.9017,29.1616
noxir.kpherox.dev,34.8587,135.509
nostr.ser1.net,12.9716,77.5946
nostr-relay.zimage.com,34.282,-118.439
nostr2.girino.org,43.6532,-79.3832
dev-relay.lnfi.network,39.0997,-94.5786
nostr.hifish.org,47.3769,8.54169
itanostr.space,52.2931,4.79099
srtrelay.c-stellar.net,43.6532,-79.3832
wot.dtonon.com,43.6532,-79.3832
relay.mwaters.net,50.9871,2.12554
relay.nostr.net,50.4754,12.3683
relay.nostx.io,43.6532,-79.3832
relay.laantungir.net,-19.4692,-42.5315
pyramid.fiatjaf.com,50.1109,8.68213
nostr.einundzwanzig.space,50.1109,8.68213
relay.satlantis.io,32.8769,-80.0114
nostr-relay.amethyst.name,35.7327,-78.8503
fanfares.nostr1.com,40.7057,-74.0136
relay.evanverma.com,40.8302,-74.1299
relay.wavlake.com,41.2619,-95.8608
relay.bitchat.party,53.5501,-113.469
nostr.yael.at,52.3563,4.95714
fenrir-s.notoshi.win,43.6532,-79.3832
relay.nostr.wirednet.jp,34.704,135.495
relay.origin.land,35.6673,139.751
relay.goodmorningbitcoin.com,43.6532,-79.3832
relay.mattybs.lol,43.6532,-79.3832
relay.nostr.vet,52.6467,4.7395
relay.arx-ccn.com,50.4754,12.3683
nostr.agentcampfire.com,50.8933,6.05805
ribo.eu.nostria.app,52.3676,4.90414
nostr.huszonegy.world,47.4979,19.0402
nostr-rs-relay-ishosta.phamthanh.me,43.6532,-79.3832
temp.iris.to,43.6532,-79.3832
relay.etch.social,41.2619,-95.8608
relay.hasenpfeffr.com,39.0438,-77.4874
relay.exit.pub,50.4754,12.3683
relay.chakany.systems,43.6532,-79.3832
nostr.coincards.com,45.4002,-75.8064
nostr.hekster.org,37.3986,-121.964
nostr-relay.psfoundation.info,39.0438,-77.4874
nostr-2.21crypto.ch,47.1928,8.81494
nostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874
relay.toastr.net,40.8054,-74.0241
relay.wolfcoil.com,35.6092,139.73
nostr-02.dorafactory.org,1.35208,103.82
portal-relay.pareto.space,49.4543,11.0746
nostr.makibisskey.work,43.6532,-79.3832
shu04.shugur.net,25.2604,55.2989
relay.lnfi.network,39.0438,-77.4874
nostr.smut.cloud,43.6532,-79.3832
nostr.diakod.com,43.6532,-79.3832
relay.lumina.rocks,49.0291,8.35695
nostr.now,36.55,139.733
relay.mockingyou.com,37.5485,-121.989
nostr.blankfors.se,60.1699,24.9384
adre.su,59.9311,30.3609
nostr.tadryanom.me,43.6532,-79.3832
relay2.angor.io,48.1046,11.6002
shu02.shugur.net,21.4902,39.2246
nostrelay.circum.space,51.2217,6.77616
nostr.spacecitynode.com,29.7057,-95.2706
vidono.apps.slidestr.net,48.8566,2.35222
relay.btcwill.win,43.6532,-79.3832
orangepiller.org,60.1699,24.9384
solo.itsalldance.space,32.71,-96.6745
relay.zone667.com,60.1699,24.9384
nostr.azzamo.net,52.2633,21.0283
internationalright-wing.org,-22.5986,-48.8003
shu05.shugur.net,48.8566,2.35222
relay.lifpay.me,1.35208,103.82
slick.mjex.me,39.048,-77.4817
theoutpost.life,64.1476,-21.9392
nostr.rohoss.com,50.1109,8.68213
relay.cosmicbolt.net,37.3986,-121.964
relay.jeffg.fyi,43.6532,-79.3832
nostr.satstralia.com,64.1476,-21.9392
relay-admin.thaliyal.com,40.8218,-74.45
social.proxymana.net,60.1699,24.9384
nostr.sathoarder.com,48.5734,7.75211
wot.nostr.place,30.2672,-97.7431
nostr-relay-1.trustlessenterprise.com,43.6532,-79.3832
relay.nsnip.io,60.1699,24.9384
nostr.zenon.network,43.5009,-70.4428
relay.copylaradio.com,50.8198,-1.08798
relay.nostrhub.tech,49.4543,11.0746
ithurtswhenip.ee,51.0238,-1.33713
nostr-01.yakihonne.com,1.32123,103.695
nostr-relay.online,43.6532,-79.3832
vitor.nostr1.com,40.7057,-74.0136
relay-testnet.k8s.layer3.news,37.3387,-121.885
schnorr.me,43.6532,-79.3832
relay.artx.market,43.652,-79.3633
nostr.21crypto.ch,47.1928,8.81494
relayone.soundhsa.com,34.0479,-118.256
relay.puresignal.news,43.6532,-79.3832
nostr.n7ekb.net,47.4941,-122.294
wot.brightbolt.net,47.6735,-116.781
nostr.overmind.lol,43.6532,-79.3832
relay.magiccity.live,25.8128,-80.2377
kitchen.zap.cooking,43.6532,-79.3832
relay.satsdays.com,1.35208,103.82
nostr-02.yakihonne.com,1.32123,103.695
offchain.pub,36.1809,-115.241
nostr.lojong.info,43.6532,-79.3832
relay.tapestry.ninja,40.8054,-74.0241
zap.watch,45.5029,-73.5723
nostrelay.memory-art.xyz,43.6532,-79.3832
nostr.spicyz.io,43.6532,-79.3832
nostr.mom,50.4754,12.3683
nos.lol,50.4754,12.3683
rnostr.breadslice.com,43.6532,-79.3832
portal-relay.pareto.space,49.4543,11.0746
relay.hasenpfeffr.com,39.0438,-77.4874
nostr.blankfors.se,60.1699,24.9384
relay5.bitransfer.org,43.6532,-79.3832
santo.iguanatech.net,40.8302,-74.1299
nostr.ser1.net,12.9716,77.5946
nostr.stakey.net,52.3676,4.90414
relayrs.notoshi.win,43.6532,-79.3832
orangepiller.org,60.1699,24.9384
temp.iris.to,43.6532,-79.3832
relay.cypherflow.ai,48.8566,2.35222
nostr.kalf.org,52.3676,4.90414
relay.unknown.cloud,43.6532,-79.3832
relay.artx.market,43.652,-79.3633
relay.arx-ccn.com,50.4754,12.3683
relay.electriclifestyle.com,26.2897,-80.1293
wot.nostr.net,43.6532,-79.3832
nostr.primz.org,43.6532,-79.3832
nostr-relay.online,43.6532,-79.3832
relay.jeffg.fyi,43.6532,-79.3832
relay.wellorder.net,45.5201,-122.99
relay01.lnfi.network,39.0997,-94.5786
noxir.kpherox.dev,34.8587,135.509
wot.sebastix.social,51.8933,4.42083
nostr.overmind.lol,43.6532,-79.3832
nostr.red5d.dev,43.6532,-79.3832
relay.0xchat.com,1.35208,103.82
relay.moinsen.com,50.4754,12.3683
relay03.lnfi.network,39.0997,-94.5786
nostr.zenon.network,43.5009,-70.4428
cyberspace.nostr1.com,40.7128,-74.006
fenrir-s.notoshi.win,43.6532,-79.3832
no.str.cr,9.92857,-84.0528
relay.nostrdice.com,-33.8688,151.209
nostr.data.haus,50.4754,12.3683
relay.g1sms.fr,43.9432,2.07537
relay.fundstr.me,42.3601,-71.0589
nostr.thaliyal.com,40.8218,-74.45
relay.mccormick.cx,52.3563,4.95714
nostr.makibisskey.work,43.6532,-79.3832
shu01.shugur.net,21.4902,39.2246
relay.angor.io,48.1046,11.6002
nostr.primz.org,43.6532,-79.3832
nostr-02.yakihonne.com,1.32123,103.695
relay.freeplace.nl,52.3676,4.90414
relay.snort.social,43.6532,-79.3832
nostr.rohoss.com,50.1109,8.68213
relay.getsafebox.app,43.6532,-79.3832
nostr.88mph.life,43.6532,-79.3832
wot.sovbit.host,64.1466,-21.9426
nostr-02.dorafactory.org,1.35208,103.82
itanostr.space,52.2931,4.79099
ribo.af.nostria.app,-26.2041,28.0473
nostr.azzamo.net,52.2633,21.0283
relay.fountain.fm,39.0997,-94.5786
relay.holzeis.me,43.6532,-79.3832
relay.sincensura.org,43.6532,-79.3832
nostr-dev.wellorder.net,45.5201,-122.99
relay.hodl.ar,-32.94,-60.6745
nostr-relay.psfoundation.info,39.0438,-77.4874
relay.goodmorningbitcoin.com,43.6532,-79.3832
nostr.rtvslawenia.com,49.4543,11.0746
relay.13room.space,43.6532,-79.3832
nostr-02.czas.top,53.471,9.88208
relay.chorus.community,50.1109,8.68213
shu04.shugur.net,25.2604,55.2989
satsage.xyz,37.3986,-121.964
relay.bullishbounty.com,43.6532,-79.3832
nostr.sagaciousd.com,49.2827,-123.121
relay.nostromo.social,49.4543,11.0746
relay.magiccity.live,25.8128,-80.2377
relay-dev.satlantis.io,40.8302,-74.1299
nostr.coincrowd.fund,39.0438,-77.4874
shu05.shugur.net,48.8566,2.35222
nostrelay.memory-art.xyz,43.6532,-79.3832
nostr.now,36.55,139.733
nostr-pub.wellorder.net,45.5201,-122.99
nos.lol,50.4754,12.3683
wot.dergigi.com,64.1476,-21.9392
inbox.azzamo.net,52.2633,21.0283
nostr.mikoshi.de,50.1109,8.68213
librerelay.aaroniumii.com,43.6532,-79.3832
relay.nosto.re,51.8933,4.42083
relay.lumina.rocks,49.0291,8.35695
kitchen.zap.cooking,43.6532,-79.3832
zap.watch,45.5029,-73.5723
nostr.spacecitynode.com,29.7057,-95.2706
nostr.bilthon.dev,25.8128,-80.2377
relay.aloftus.io,34.0881,-118.379
shu02.shugur.net,21.4902,39.2246
relay.mwaters.net,50.9871,2.12554
relay.moinsen.com,50.4754,12.3683
nostr.kungfu-g.rip,33.7946,-84.4488
nostr.sathoarder.com,48.5734,7.75211
nostream.breadslice.com,43.6532,-79.3832
slick.mjex.me,39.048,-77.4817
nostr.jerrynya.fun,31.2304,121.474
articles.layer3.news,37.3387,-121.885
nostr.satstralia.com,64.1476,-21.9392
relay.degmods.com,50.4754,12.3683
relay.0xchat.com,1.35208,103.82
relay.nostx.io,43.6532,-79.3832
relay.siamdev.cc,13.9178,100.424
relay.mattybs.lol,43.6532,-79.3832
relay.bitcoinveneto.org,64.1466,-21.9426
nostr.tadryanom.me,43.6532,-79.3832
nostr.spaceshell.xyz,43.6532,-79.3832
relay.nostr.band,60.1699,24.9384
relay03.lnfi.network,39.0997,-94.5786
schnorr.me,43.6532,-79.3832
khatru.nostrver.se,51.8933,4.42083
dealtime-footwear-angry-records.trycloudflare.com,43.6532,-79.3832
relay.endfiat.money,43.6532,-79.3832
internationalright-wing.org,-22.5022,-48.7114
nostr.mom,50.4754,12.3683
nostrue.com,40.8054,-74.0241
relay.chakany.systems,43.6532,-79.3832
vidono.apps.slidestr.net,48.8566,2.35222
relay.stream.labs.h3.se,59.4016,17.9455
gnostr.com,40.9017,29.1616
relay.origin.land,35.6673,139.751
relay.sigit.io,50.4754,12.3683
theoutpost.life,64.1476,-21.9392
adre.su,59.9311,30.3609
soloco.nl,43.6532,-79.3832
wot.soundhsa.com,34.0479,-118.256
nos.xmark.cc,50.6924,3.20113
nostr-relay.zimage.com,34.282,-118.439
nostr.pleb.one,38.6327,-90.1961
purpura.cloud,43.6532,-79.3832
ribo.us.nostria.app,41.5868,-93.625
relay.digitalezukunft.cyou,45.5019,-73.5674
relay.mess.ch,47.3591,8.55292
relay.javi.space,43.4633,11.8796
nostr.jfischer.org,49.0291,8.35696
nostr.rblb.it,43.4633,11.8796
relay.nostriot.com,41.5695,-83.9786
nostr.21crypto.ch,47.4988,8.72369
relay.nostr.vet,52.6467,4.7395
fanfares.nostr1.com,40.7057,-74.0136
relay.copylaradio.com,51.223,6.78245
relay.mostro.network,40.8302,-74.1299
nostr-rs-relay-ishosta.phamthanh.me,43.6532,-79.3832
relay.lifpay.me,1.35208,103.82
nostr.tac.lol,47.4748,-122.273
relay.nostraddress.com,43.6532,-79.3832
wot.sudocarlos.com,51.5072,-0.127586
nostr.spicyz.io,43.6532,-79.3832
relay02.lnfi.network,39.0997,-94.5786
nostr.4rs.nl,49.0291,8.35696
relay.21e6.cz,50.1682,14.0546
nostr.casa21.space,43.6532,-79.3832
wot.codingarena.top,50.4754,12.3683
nostr-01.yakihonne.com,1.32123,103.695
strfry.openhoofd.nl,51.9229,4.40833
nostr.plantroon.com,50.1013,8.62643
dev-nostr.bityacht.io,25.0797,121.234
ribo.eu.nostria.app,52.3676,4.90414
wot.nostr.place,30.2672,-97.7431
relay.dwadziesciajeden.pl,52.2297,21.0122
prl.plus,55.7623,37.6381
relay.bitcoinartclock.com,50.4754,12.3683
nostr.2b9t.xyz,34.0549,-118.243
a.nos.lol,50.4754,12.3683
relay.wavlake.com,41.2619,-95.8608
relay.toastr.net,40.8054,-74.0241
wheat.happytavern.co,43.6532,-79.3832
relay.lnfi.network,39.0438,-77.4874
nostr.liberty.fans,36.9104,-89.5875
vitor.nostr1.com,40.7057,-74.0136
nostr.thebiglake.org,32.71,-96.6745
relay.exit.pub,50.4754,12.3683
freelay.sovbit.host,64.1476,-21.9392
purplerelay.com,50.1109,8.68213
nostr.camalolo.com,24.1469,120.684
black.nostrcity.club,41.8781,-87.6298
relay.barine.co,43.6532,-79.3832
nostr.0x7e.xyz,47.4988,8.72369
relayone.soundhsa.com,34.0479,-118.256
relay.vrtmrz.net,43.6532,-79.3832
nostr-03.dorafactory.org,1.35208,103.82
r.bitcoinhold.net,43.6532,-79.3832
relay.agorist.space,52.3734,4.89406
nostr-relay.amethyst.name,39.0067,-77.4291
relay-rpi.edufeed.org,49.4543,11.0746
orangesync.tech,50.1109,8.68213
relay04.lnfi.network,39.0997,-94.5786
nostr.hifish.org,47.4043,8.57398
relay.nostr.wirednet.jp,34.706,135.493
nostr.girino.org,43.6532,-79.3832
strfry.felixzieger.de,50.1013,8.62643
nostr.night7.space,50.4754,12.3683
nostr.oxtr.dev,50.4754,12.3683
relay.nostrhub.fr,48.1046,11.6002
nostr.rikmeijer.nl,50.4754,12.3683
wot.basspistol.org,49.4521,11.0767
nostr.coincards.com,53.5501,-113.469
solo.itsalldance.space,32.71,-96.6745
relay.puresignal.news,43.6532,-79.3832
nostrelay.circum.space,51.2217,6.77616
relay.btcforplebs.com,43.6532,-79.3832
nostr.diakod.com,43.6532,-79.3832
tollbooth.stens.dev,51.223,6.78245
relay.usefusion.ai,39.0438,-77.4874
relay-admin.thaliyal.com,40.8218,-74.45
nproxy.kristapsk.lv,60.1699,24.9384
nostr-verified.wellorder.net,45.5201,-122.99
wot.brightbolt.net,47.6735,-116.781
relay.orangepill.ovh,49.1689,-0.358841
relay.olas.app,50.4754,12.3683
offchain.pub,36.1809,-115.241
nostr.l484.com,30.2944,-97.6223
relay.nostrhub.tech,49.4543,11.0746
relay.tapestry.ninja,40.8054,-74.0241
nostr.snowbla.de,60.1699,24.9384
relay.letsfo.com,51.098,17.0321
relay.tagayasu.xyz,43.6715,-79.38
strfry.bonsai.com,37.8715,-122.273
nostr-relay-1.trustlessenterprise.com,43.6532,-79.3832
nostr.smut.cloud,43.6532,-79.3832
x.kojira.io,43.6532,-79.3832
yabu.me,35.6092,139.73
relay.zone667.com,60.1699,24.9384
relay.bitcoindistrict.org,43.6532,-79.3832
pyramid.fiatjaf.com,50.1109,8.68213
nostr.hekster.org,37.3986,-121.964
relay.laantungir.net,-19.4692,-42.5315
relay.cosmicbolt.net,37.3986,-121.964
strfry.shock.network,41.8959,-88.2169
relay.coinos.io,43.6532,-79.3832
wot.nostr.net,43.6532,-79.3832
nostr.einundzwanzig.space,50.1109,8.68213
premium.primal.net,43.6532,-79.3832
dev-relay.lnfi.network,39.0997,-94.5786
ynostr.yael.at,60.1699,24.9384
relay.fr13nd5.com,52.5233,13.3426
nostr.lojong.info,43.6532,-79.3832
srtrelay.c-stellar.net,43.6532,-79.3832
nostr.myshosholoza.co.za,52.3676,4.90414
alien.macneilmediagroup.com,43.6532,-79.3832
relay.utxo.farm,35.6916,139.768
relay.ru.ac.th,13.7584,100.622
relay2.angor.io,48.1046,11.6002
relay.seq1.net,43.6532,-79.3832
relay.illuminodes.com,47.6061,-122.333
relay.satlantis.io,32.8769,-80.0114
relay.primal.net,43.6532,-79.3832
nostr.vulpem.com,49.4543,11.0746
relay.evanverma.com,40.8302,-74.1299
nostr.chaima.info,51.223,6.78245
relay.notoshi.win,13.7829,100.546
nostr.carroarmato0.be,50.9928,3.26317
relay.ditto.pub,43.6532,-79.3832
relay.credenso.cafe,43.1149,-80.7228
relay.nostr.place,32.7767,-96.797
relay.conduit.market,43.6532,-79.3832
nostr.huszonegy.world,47.4979,19.0402
nostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874
wot.nostr.party,36.1627,-86.7816
wot.dtonon.com,43.6532,-79.3832
nostr.middling.mydns.jp,35.8099,140.12
nostr-2.21crypto.ch,47.4988,8.72369
nostr.dlsouza.lol,50.1109,8.68213
1 Relay URL Latitude Longitude
relay.chorus.community 50.1109 8.68213
relay-rpi.edufeed.org 49.4543 11.0746
nostr.chaima.info 50.1109 8.68213
relay.nostraddress.com 43.6532 -79.3832
nostr.middling.mydns.jp 35.8099 140.12
nostr.myshosholoza.co.za 52.3676 4.90414
n.ok0.org -37.7254 176.133
relay.21e6.cz 50.1682 14.0546
premium.primal.net 43.6532 -79.3832
relay.stream.labs.h3.se 59.4016 17.9455
nostr.night7.space 50.4754 12.3683
nostr.4rs.nl 49.0291 8.35696
relay.notoshi.win 13.7683 100.54
nostr.carroarmato0.be 50.9017 3.52588
relay.angor.io 48.1046 11.6002
relay.nostriot.com 41.5695 -83.9786
nostr-pub.wellorder.net 45.5201 -122.99
relay04.lnfi.network 39.0997 -94.5786
x.kojira.io 43.6532 -79.3832
strfry.felixzieger.de 50.1013 8.62643
purpura.cloud 43.6532 -79.3832
alien.macneilmediagroup.com 43.6532 -79.3832
wot.sudocarlos.com 51.5072 -0.127586
relay02.lnfi.network 39.0997 -94.5786
relay.getsafebox.app 43.6532 -79.3832
wot.sebastix.social 51.8933 4.42083
relay.dwadziesciajeden.pl 52.2297 21.0122
nostr.roundrockbitcoiners.com 40.8054 -74.0241
nostr.dlsouza.lol 50.1109 8.68213
nostr-relay.shirogaku.xyz 43.6532 -79.3832
yabu.me 35.6092 139.73
nostr.stakey.net 52.3676 4.90414
relay.sincensura.org 43.6532 -79.3832
nostr-02.czas.top 51.2277 6.77346
soloco.nl 43.6532 -79.3832
nostream.breadslice.com 43.6532 -79.3832
nostr.camalolo.com 24.1469 120.684
nostr-dev.wellorder.net 45.5201 -122.99
nostr.pleb.one 38.6327 -90.1961
cyberspace.nostr1.com 40.7357 -74.1724
relay.illuminodes.com 47.6061 -122.333
satsage.xyz 37.3986 -121.964
nostr.data.haus 50.4754 12.3683
nostr.jfischer.org 49.0291 8.35696
r.bitcoinhold.net 43.6532 -79.3832
relay.barine.co 43.6532 -79.3832
wot.sovbit.host 64.1466 -21.9426
nostr.vulpem.com 49.4543 11.0746
nostr.kalf.org 52.3676 4.90414
nostr.thebiglake.org 32.71 -96.6745
relay.bitcoinveneto.org 64.1466 -21.9426
wot.dergigi.com 64.1476 -21.9392
nos.xmark.cc 50.6924 3.20113
nostr.88mph.life 43.6532 -79.3832
relay.mess.ch 47.3591 8.55292
nostr.thaliyal.com 40.8218 -74.45
nostr.notribe.net 40.8302 -74.1299
relay.orangepill.ovh 49.1689 -0.358841
relay.artiostr.ch 43.6532 -79.3832
relay.13room.space 43.6532 -79.3832
relay.primal.net 43.6532 -79.3832
strfry.shock.network 41.8959 -88.2169
nostr.2b9t.xyz 34.0549 -118.243
ribo.af.nostria.app -26.2041 28.0473
a.nos.lol 50.4754 12.3683
relay.mccormick.cx 52.3563 4.95714
relay.mostro.network 40.8302 -74.1299
relay.bitcoinartclock.com 50.4754 12.3683
nostr-relay.cbrx.io 43.6532 -79.3832
prl.plus 55.7623 37.6381
relay.endfiat.money 43.6532 -79.3832
relay.hook.cafe 43.6532 -79.3832
relay.siamdev.cc 13.9178 100.424
wot.nostr.party 36.1627 -86.7816
wot.basspistol.org 49.4521 11.0767
dev-nostr.bityacht.io 25.0797 121.234
orangesync.tech 51.5072 -0.127586
nostr.casa21.space 43.6532 -79.3832
relay.digitalezukunft.cyou 45.5019 -73.5674
relay.nostrcheck.me 43.6532 -79.3832
inbox.mycelium.social 38.627 -90.1994
nostr.liberty.fans 36.9104 -89.5875
nostr-verified.wellorder.net 45.5201 -122.99
relay.sigit.io 50.4754 12.3683
tollbooth.stens.dev 51.223 6.78245
relay.nostromo.social 49.4543 11.0746
wheat.happytavern.co 43.6532 -79.3832
relay.vrtmrz.net 43.6532 -79.3832
relayrs.notoshi.win 43.6532 -79.3832
travis-shears-nostr-relay-v2.fly.dev 41.8781 -87.6298
nostr.community.ath.cx 45.5029 -73.5723
wot.girino.org 43.6532 -79.3832
nostr.coincrowd.fund 39.0438 -77.4874
relay.freeplace.nl 52.3676 4.90414
nostr-03.dorafactory.org 1.35208 103.82
relay5.bitransfer.org 43.6532 -79.3832
nostr.tac.lol 47.4748 -122.273
nostr.sagaciousd.com 49.2827 -123.121
nostr.girino.org 43.6532 -79.3832
nostr.jerrynya.fun 31.2304 121.474
relay.agorist.space 52.3734 4.89406
inbox.azzamo.net 52.2633 21.0283
wot.codingarena.top 50.4754 12.3683
relay.degmods.com 50.4754 12.3683
articles.layer3.news 37.3387 -121.885
nostr.rblb.it 43.4633 11.8796
relay.davidebtc.me 51.5072 -0.127586
relay.hodl.ar -34.5904 -58.629
nostr.rtvslawenia.com 49.4543 11.0746
relay01.lnfi.network 39.0997 -94.5786
strfry.bonsai.com 37.8715 -122.273
relay.nostr.place 32.7767 -96.797
relay.bitcoindistrict.org 43.6532 -79.3832
relay.aloftus.io 34.0881 -118.379
nostr.massmux.com 51.5072 -0.127586
relay.wellorder.net 45.5201 -122.99
santo.iguanatech.net 40.8302 -74.1299
2 relay.damus.io 43.6532 -79.3832
relay.javi.space 43.4633 11.8796
relay.nosto.re 51.8933 4.42083
relay.utxo.farm 35.6916 139.768
relay.fr13nd5.com 52.5233 13.3426
librerelay.aaroniumii.com 43.6532 -79.3832
relay.cypherflow.ai 48.8566 2.35222
relay.letsfo.com 51.098 17.0321
nostr.0x7e.xyz 47.4988 8.72369
relay.bullishbounty.com 43.6532 -79.3832
relay.tagayasu.xyz 43.6715 -79.38
nostr.plantroon.com 50.1013 8.62643
strfry.openhoofd.nl 51.9229 4.40833
nostrelites.org 41.8781 -87.6298
relay.credenso.cafe 43.1149 -80.7228
relay.conduit.market 43.6532 -79.3832
relay.olas.app 50.4754 12.3683
relay.nostrdice.com -33.8688 151.209
nostr.oxtr.dev 50.4754 12.3683
relay.nostr.band 60.1699 24.9384
khatru.nostrver.se 51.8933 4.42083
relay.coinos.io 43.6532 -79.3832
nostr.l484.com 30.2944 -97.6223
purplerelay.com 50.1109 8.68213
relay.nostrhub.fr 48.1046 11.6002
black.nostrcity.club 41.8781 -87.6298
nostr.snowbla.de 60.1699 24.9384
no.str.cr 9.93388 -84.0849
nostr.rikmeijer.nl 50.4754 12.3683
nproxy.kristapsk.lv 60.1699 24.9384
wot.soundhsa.com 34.0479 -118.256
nostr.kungfu-g.rip 33.7946 -84.4488
nostr.spaceshell.xyz 43.6532 -79.3832
nostr.mikoshi.de 50.1109 8.68213
gnostr.com 40.9017 29.1616
noxir.kpherox.dev 34.8587 135.509
nostr.ser1.net 12.9716 77.5946
nostr-relay.zimage.com 34.282 -118.439
3 nostr2.girino.org 43.6532 -79.3832
dev-relay.lnfi.network 39.0997 -94.5786
nostr.hifish.org 47.3769 8.54169
itanostr.space 52.2931 4.79099
srtrelay.c-stellar.net 43.6532 -79.3832
wot.dtonon.com 43.6532 -79.3832
relay.mwaters.net 50.9871 2.12554
relay.nostr.net 50.4754 12.3683
relay.nostx.io 43.6532 -79.3832
relay.laantungir.net -19.4692 -42.5315
pyramid.fiatjaf.com 50.1109 8.68213
nostr.einundzwanzig.space 50.1109 8.68213
relay.satlantis.io 32.8769 -80.0114
nostr-relay.amethyst.name 35.7327 -78.8503
fanfares.nostr1.com 40.7057 -74.0136
relay.evanverma.com 40.8302 -74.1299
relay.wavlake.com 41.2619 -95.8608
relay.bitchat.party 53.5501 -113.469
nostr.yael.at 52.3563 4.95714
fenrir-s.notoshi.win 43.6532 -79.3832
relay.nostr.wirednet.jp 34.704 135.495
relay.origin.land 35.6673 139.751
relay.goodmorningbitcoin.com 43.6532 -79.3832
relay.mattybs.lol 43.6532 -79.3832
relay.nostr.vet 52.6467 4.7395
relay.arx-ccn.com 50.4754 12.3683
nostr.agentcampfire.com 50.8933 6.05805
ribo.eu.nostria.app 52.3676 4.90414
nostr.huszonegy.world 47.4979 19.0402
nostr-rs-relay-ishosta.phamthanh.me 43.6532 -79.3832
temp.iris.to 43.6532 -79.3832
relay.etch.social 41.2619 -95.8608
relay.hasenpfeffr.com 39.0438 -77.4874
relay.exit.pub 50.4754 12.3683
relay.chakany.systems 43.6532 -79.3832
nostr.coincards.com 45.4002 -75.8064
nostr.hekster.org 37.3986 -121.964
nostr-relay.psfoundation.info 39.0438 -77.4874
nostr-2.21crypto.ch 47.1928 8.81494
nostr-rs-relay.dev.fedibtc.com 39.0438 -77.4874
relay.toastr.net 40.8054 -74.0241
relay.wolfcoil.com 35.6092 139.73
nostr-02.dorafactory.org 1.35208 103.82
portal-relay.pareto.space 49.4543 11.0746
nostr.makibisskey.work 43.6532 -79.3832
shu04.shugur.net 25.2604 55.2989
relay.lnfi.network 39.0438 -77.4874
nostr.smut.cloud 43.6532 -79.3832
nostr.diakod.com 43.6532 -79.3832
relay.lumina.rocks 49.0291 8.35695
nostr.now 36.55 139.733
relay.mockingyou.com 37.5485 -121.989
nostr.blankfors.se 60.1699 24.9384
adre.su 59.9311 30.3609
nostr.tadryanom.me 43.6532 -79.3832
relay2.angor.io 48.1046 11.6002
shu02.shugur.net 21.4902 39.2246
nostrelay.circum.space 51.2217 6.77616
nostr.spacecitynode.com 29.7057 -95.2706
vidono.apps.slidestr.net 48.8566 2.35222
relay.btcwill.win 43.6532 -79.3832
orangepiller.org 60.1699 24.9384
solo.itsalldance.space 32.71 -96.6745
relay.zone667.com 60.1699 24.9384
nostr.azzamo.net 52.2633 21.0283
internationalright-wing.org -22.5986 -48.8003
shu05.shugur.net 48.8566 2.35222
relay.lifpay.me 1.35208 103.82
slick.mjex.me 39.048 -77.4817
theoutpost.life 64.1476 -21.9392
nostr.rohoss.com 50.1109 8.68213
relay.cosmicbolt.net 37.3986 -121.964
relay.jeffg.fyi 43.6532 -79.3832
nostr.satstralia.com 64.1476 -21.9392
relay-admin.thaliyal.com 40.8218 -74.45
social.proxymana.net 60.1699 24.9384
nostr.sathoarder.com 48.5734 7.75211
wot.nostr.place 30.2672 -97.7431
nostr-relay-1.trustlessenterprise.com 43.6532 -79.3832
relay.nsnip.io 60.1699 24.9384
nostr.zenon.network 43.5009 -70.4428
relay.copylaradio.com 50.8198 -1.08798
relay.nostrhub.tech 49.4543 11.0746
ithurtswhenip.ee 51.0238 -1.33713
nostr-01.yakihonne.com 1.32123 103.695
nostr-relay.online 43.6532 -79.3832
vitor.nostr1.com 40.7057 -74.0136
relay-testnet.k8s.layer3.news 37.3387 -121.885
schnorr.me 43.6532 -79.3832
relay.artx.market 43.652 -79.3633
nostr.21crypto.ch 47.1928 8.81494
relayone.soundhsa.com 34.0479 -118.256
relay.puresignal.news 43.6532 -79.3832
4 nostr.n7ekb.net 47.4941 -122.294
5 wot.brightbolt.net rnostr.breadslice.com 47.6735 43.6532 -116.781 -79.3832
6 nostr.overmind.lol portal-relay.pareto.space 43.6532 49.4543 -79.3832 11.0746
7 relay.magiccity.live relay.hasenpfeffr.com 25.8128 39.0438 -80.2377 -77.4874
8 kitchen.zap.cooking nostr.blankfors.se 43.6532 60.1699 -79.3832 24.9384
9 relay.satsdays.com relay5.bitransfer.org 1.35208 43.6532 103.82 -79.3832
10 nostr-02.yakihonne.com santo.iguanatech.net 1.32123 40.8302 103.695 -74.1299
11 offchain.pub nostr.ser1.net 36.1809 12.9716 -115.241 77.5946
12 nostr.lojong.info nostr.stakey.net 43.6532 52.3676 -79.3832 4.90414
13 relay.tapestry.ninja relayrs.notoshi.win 40.8054 43.6532 -74.0241 -79.3832
14 zap.watch orangepiller.org 45.5029 60.1699 -73.5723 24.9384
15 nostrelay.memory-art.xyz temp.iris.to 43.6532 -79.3832
16 nostr.spicyz.io relay.cypherflow.ai 43.6532 48.8566 -79.3832 2.35222
17 nostr.mom nostr.kalf.org 50.4754 52.3676 12.3683 4.90414
18 nos.lol relay.unknown.cloud 50.4754 43.6532 12.3683 -79.3832
19 relay.artx.market 43.652 -79.3633
20 relay.arx-ccn.com 50.4754 12.3683
21 relay.electriclifestyle.com 26.2897 -80.1293
22 wot.nostr.net nostr-relay.online 43.6532 -79.3832
23 nostr.primz.org relay.jeffg.fyi 43.6532 -79.3832
24 relay.wellorder.net 45.5201 -122.99
25 relay01.lnfi.network 39.0997 -94.5786
26 noxir.kpherox.dev 34.8587 135.509
27 wot.sebastix.social 51.8933 4.42083
28 nostr.overmind.lol 43.6532 -79.3832
29 nostr.red5d.dev 43.6532 -79.3832
30 relay.0xchat.com nostr.zenon.network 1.35208 43.5009 103.82 -70.4428
31 relay.moinsen.com cyberspace.nostr1.com 50.4754 40.7128 12.3683 -74.006
32 relay03.lnfi.network fenrir-s.notoshi.win 39.0997 43.6532 -94.5786 -79.3832
33 no.str.cr 9.92857 -84.0528
34 relay.nostrdice.com -33.8688 151.209
35 nostr.data.haus 50.4754 12.3683
36 relay.g1sms.fr 43.9432 2.07537
37 relay.fundstr.me 42.3601 -71.0589
38 nostr.thaliyal.com 40.8218 -74.45
39 relay.mccormick.cx 52.3563 4.95714
40 nostr.makibisskey.work 43.6532 -79.3832
41 shu01.shugur.net 21.4902 39.2246
42 relay.angor.io 48.1046 11.6002
43 nostr.primz.org 43.6532 -79.3832
44 nostr-02.yakihonne.com 1.32123 103.695
45 relay.freeplace.nl 52.3676 4.90414
46 relay.snort.social 43.6532 -79.3832
47 nostr.rohoss.com 50.1109 8.68213
48 relay.getsafebox.app 43.6532 -79.3832
49 nostr.88mph.life 43.6532 -79.3832
50 wot.sovbit.host 64.1466 -21.9426
51 nostr-02.dorafactory.org 1.35208 103.82
52 itanostr.space 52.2931 4.79099
53 ribo.af.nostria.app -26.2041 28.0473
54 nostr.azzamo.net 52.2633 21.0283
55 relay.fountain.fm 39.0997 -94.5786
56 relay.holzeis.me 43.6532 -79.3832
57 relay.sincensura.org 43.6532 -79.3832
58 nostr-dev.wellorder.net 45.5201 -122.99
59 relay.hodl.ar -32.94 -60.6745
60 nostr-relay.psfoundation.info 39.0438 -77.4874
61 relay.goodmorningbitcoin.com 43.6532 -79.3832
62 nostr.rtvslawenia.com 49.4543 11.0746
63 relay.13room.space 43.6532 -79.3832
64 nostr-02.czas.top 53.471 9.88208
65 relay.chorus.community 50.1109 8.68213
66 shu04.shugur.net 25.2604 55.2989
67 satsage.xyz 37.3986 -121.964
68 relay.bullishbounty.com 43.6532 -79.3832
69 nostr.sagaciousd.com 49.2827 -123.121
70 relay.nostromo.social 49.4543 11.0746
71 relay.magiccity.live 25.8128 -80.2377
72 relay-dev.satlantis.io 40.8302 -74.1299
73 nostr.coincrowd.fund 39.0438 -77.4874
74 shu05.shugur.net 48.8566 2.35222
75 nostrelay.memory-art.xyz 43.6532 -79.3832
76 nostr.now 36.55 139.733
77 nostr-pub.wellorder.net 45.5201 -122.99
78 nos.lol 50.4754 12.3683
79 wot.dergigi.com 64.1476 -21.9392
80 inbox.azzamo.net 52.2633 21.0283
81 nostr.mikoshi.de 50.1109 8.68213
82 librerelay.aaroniumii.com 43.6532 -79.3832
83 relay.nosto.re 51.8933 4.42083
84 relay.lumina.rocks 49.0291 8.35695
85 kitchen.zap.cooking 43.6532 -79.3832
86 zap.watch 45.5029 -73.5723
87 nostr.spacecitynode.com 29.7057 -95.2706
88 nostr.bilthon.dev 25.8128 -80.2377
89 relay.aloftus.io 34.0881 -118.379
90 shu02.shugur.net 21.4902 39.2246
91 relay.mwaters.net 50.9871 2.12554
92 relay.moinsen.com 50.4754 12.3683
93 nostr.kungfu-g.rip 33.7946 -84.4488
94 nostr.sathoarder.com 48.5734 7.75211
95 nostream.breadslice.com 43.6532 -79.3832
96 slick.mjex.me 39.048 -77.4817
97 nostr.jerrynya.fun 31.2304 121.474
98 articles.layer3.news 37.3387 -121.885
99 nostr.satstralia.com 64.1476 -21.9392
100 relay.degmods.com 50.4754 12.3683
101 relay.0xchat.com 1.35208 103.82
102 relay.nostx.io 43.6532 -79.3832
103 relay.siamdev.cc 13.9178 100.424
104 relay.mattybs.lol 43.6532 -79.3832
105 relay.bitcoinveneto.org 64.1466 -21.9426
106 nostr.tadryanom.me 43.6532 -79.3832
107 nostr.spaceshell.xyz 43.6532 -79.3832
108 relay.nostr.band 60.1699 24.9384
109 relay03.lnfi.network 39.0997 -94.5786
110 schnorr.me 43.6532 -79.3832
111 khatru.nostrver.se 51.8933 4.42083
112 dealtime-footwear-angry-records.trycloudflare.com 43.6532 -79.3832
113 relay.endfiat.money 43.6532 -79.3832
114 internationalright-wing.org -22.5022 -48.7114
115 nostr.mom 50.4754 12.3683
116 nostrue.com 40.8054 -74.0241
117 relay.chakany.systems 43.6532 -79.3832
118 vidono.apps.slidestr.net 48.8566 2.35222
119 relay.stream.labs.h3.se 59.4016 17.9455
120 gnostr.com 40.9017 29.1616
121 relay.origin.land 35.6673 139.751
122 relay.sigit.io 50.4754 12.3683
123 theoutpost.life 64.1476 -21.9392
124 adre.su 59.9311 30.3609
125 soloco.nl 43.6532 -79.3832
126 wot.soundhsa.com 34.0479 -118.256
127 nos.xmark.cc 50.6924 3.20113
128 nostr-relay.zimage.com 34.282 -118.439
129 nostr.pleb.one 38.6327 -90.1961
130 purpura.cloud 43.6532 -79.3832
131 ribo.us.nostria.app 41.5868 -93.625
132 relay.digitalezukunft.cyou 45.5019 -73.5674
133 relay.mess.ch 47.3591 8.55292
134 relay.javi.space 43.4633 11.8796
135 nostr.jfischer.org 49.0291 8.35696
136 nostr.rblb.it 43.4633 11.8796
137 relay.nostriot.com 41.5695 -83.9786
138 nostr.21crypto.ch 47.4988 8.72369
139 relay.nostr.vet 52.6467 4.7395
140 fanfares.nostr1.com 40.7057 -74.0136
141 relay.copylaradio.com 51.223 6.78245
142 relay.mostro.network 40.8302 -74.1299
143 nostr-rs-relay-ishosta.phamthanh.me 43.6532 -79.3832
144 relay.lifpay.me 1.35208 103.82
145 nostr.tac.lol 47.4748 -122.273
146 relay.nostraddress.com 43.6532 -79.3832
147 wot.sudocarlos.com 51.5072 -0.127586
148 nostr.spicyz.io 43.6532 -79.3832
149 relay02.lnfi.network 39.0997 -94.5786
150 nostr.4rs.nl 49.0291 8.35696
151 relay.21e6.cz 50.1682 14.0546
152 nostr.casa21.space 43.6532 -79.3832
153 wot.codingarena.top 50.4754 12.3683
154 nostr-01.yakihonne.com 1.32123 103.695
155 strfry.openhoofd.nl 51.9229 4.40833
156 nostr.plantroon.com 50.1013 8.62643
157 dev-nostr.bityacht.io 25.0797 121.234
158 ribo.eu.nostria.app 52.3676 4.90414
159 wot.nostr.place 30.2672 -97.7431
160 relay.dwadziesciajeden.pl 52.2297 21.0122
161 prl.plus 55.7623 37.6381
162 relay.bitcoinartclock.com 50.4754 12.3683
163 nostr.2b9t.xyz 34.0549 -118.243
164 a.nos.lol 50.4754 12.3683
165 relay.wavlake.com 41.2619 -95.8608
166 relay.toastr.net 40.8054 -74.0241
167 wheat.happytavern.co 43.6532 -79.3832
168 relay.lnfi.network 39.0438 -77.4874
169 nostr.liberty.fans 36.9104 -89.5875
170 vitor.nostr1.com 40.7057 -74.0136
171 nostr.thebiglake.org 32.71 -96.6745
172 relay.exit.pub 50.4754 12.3683
173 freelay.sovbit.host 64.1476 -21.9392
174 purplerelay.com 50.1109 8.68213
175 nostr.camalolo.com 24.1469 120.684
176 black.nostrcity.club 41.8781 -87.6298
177 relay.barine.co 43.6532 -79.3832
178 nostr.0x7e.xyz 47.4988 8.72369
179 relayone.soundhsa.com 34.0479 -118.256
180 relay.vrtmrz.net 43.6532 -79.3832
181 nostr-03.dorafactory.org 1.35208 103.82
182 r.bitcoinhold.net 43.6532 -79.3832
183 relay.agorist.space 52.3734 4.89406
184 nostr-relay.amethyst.name 39.0067 -77.4291
185 relay-rpi.edufeed.org 49.4543 11.0746
186 orangesync.tech 50.1109 8.68213
187 relay04.lnfi.network 39.0997 -94.5786
188 nostr.hifish.org 47.4043 8.57398
189 relay.nostr.wirednet.jp 34.706 135.493
190 nostr.girino.org 43.6532 -79.3832
191 strfry.felixzieger.de 50.1013 8.62643
192 nostr.night7.space 50.4754 12.3683
193 nostr.oxtr.dev 50.4754 12.3683
194 relay.nostrhub.fr 48.1046 11.6002
195 nostr.rikmeijer.nl 50.4754 12.3683
196 wot.basspistol.org 49.4521 11.0767
197 nostr.coincards.com 53.5501 -113.469
198 solo.itsalldance.space 32.71 -96.6745
199 relay.puresignal.news 43.6532 -79.3832
200 nostrelay.circum.space 51.2217 6.77616
201 relay.btcforplebs.com 43.6532 -79.3832
202 nostr.diakod.com 43.6532 -79.3832
203 tollbooth.stens.dev 51.223 6.78245
204 relay.usefusion.ai 39.0438 -77.4874
205 relay-admin.thaliyal.com 40.8218 -74.45
206 nproxy.kristapsk.lv 60.1699 24.9384
207 nostr-verified.wellorder.net 45.5201 -122.99
208 wot.brightbolt.net 47.6735 -116.781
209 relay.orangepill.ovh 49.1689 -0.358841
210 relay.olas.app 50.4754 12.3683
211 offchain.pub 36.1809 -115.241
212 nostr.l484.com 30.2944 -97.6223
213 relay.nostrhub.tech 49.4543 11.0746
214 relay.tapestry.ninja 40.8054 -74.0241
215 nostr.snowbla.de 60.1699 24.9384
216 relay.letsfo.com 51.098 17.0321
217 relay.tagayasu.xyz 43.6715 -79.38
218 strfry.bonsai.com 37.8715 -122.273
219 nostr-relay-1.trustlessenterprise.com 43.6532 -79.3832
220 nostr.smut.cloud 43.6532 -79.3832
221 x.kojira.io 43.6532 -79.3832
222 yabu.me 35.6092 139.73
223 relay.zone667.com 60.1699 24.9384
224 relay.bitcoindistrict.org 43.6532 -79.3832
225 pyramid.fiatjaf.com 50.1109 8.68213
226 nostr.hekster.org 37.3986 -121.964
227 relay.laantungir.net -19.4692 -42.5315
228 relay.cosmicbolt.net 37.3986 -121.964
229 strfry.shock.network 41.8959 -88.2169
230 relay.coinos.io 43.6532 -79.3832
231 wot.nostr.net 43.6532 -79.3832
232 nostr.einundzwanzig.space 50.1109 8.68213
233 premium.primal.net 43.6532 -79.3832
234 dev-relay.lnfi.network 39.0997 -94.5786
235 ynostr.yael.at 60.1699 24.9384
236 relay.fr13nd5.com 52.5233 13.3426
237 nostr.lojong.info 43.6532 -79.3832
238 srtrelay.c-stellar.net 43.6532 -79.3832
239 nostr.myshosholoza.co.za 52.3676 4.90414
240 alien.macneilmediagroup.com 43.6532 -79.3832
241 relay.utxo.farm 35.6916 139.768
242 relay.ru.ac.th 13.7584 100.622
243 relay2.angor.io 48.1046 11.6002
244 relay.seq1.net 43.6532 -79.3832
245 relay.illuminodes.com 47.6061 -122.333
246 relay.satlantis.io 32.8769 -80.0114
247 relay.primal.net 43.6532 -79.3832
248 nostr.vulpem.com 49.4543 11.0746
249 relay.evanverma.com 40.8302 -74.1299
250 nostr.chaima.info 51.223 6.78245
251 relay.notoshi.win 13.7829 100.546
252 nostr.carroarmato0.be 50.9928 3.26317
253 relay.ditto.pub 43.6532 -79.3832
254 relay.credenso.cafe 43.1149 -80.7228
255 relay.nostr.place 32.7767 -96.797
256 relay.conduit.market 43.6532 -79.3832
257 nostr.huszonegy.world 47.4979 19.0402
258 nostr-rs-relay.dev.fedibtc.com 39.0438 -77.4874
259 wot.nostr.party 36.1627 -86.7816
260 wot.dtonon.com 43.6532 -79.3832
261 nostr.middling.mydns.jp 35.8099 140.12
262 nostr-2.21crypto.ch 47.4988 8.72369
263 nostr.dlsouza.lol 50.1109 8.68213
@@ -9,11 +9,12 @@ import java.util.*
/**
* Bridging Noise and Nostr favorites
* Direct port from iOS FavoritesPersistenceService.swift
* Direct port from iOS FavoritesPersistenceService.swift, with Android-specific
* peerID (16-hex) -> npub indexing for Nostr DM routing.
*/
data class FavoriteRelationship(
val peerNoisePublicKey: ByteArray, // Noise static public key (32 bytes)
val peerNostrPublicKey: String?, // npub bech32 string
val peerNostrPublicKey: String?, // npub bech32 string
val peerNickname: String,
val isFavorite: Boolean, // We favorited them
val theyFavoritedUs: Boolean, // They favorited us
@@ -21,22 +22,22 @@ data class FavoriteRelationship(
val lastUpdated: Date
) {
val isMutual: Boolean get() = isFavorite && theyFavoritedUs
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as FavoriteRelationship
if (!peerNoisePublicKey.contentEquals(other.peerNoisePublicKey)) return false
if (peerNostrPublicKey != other.peerNostrPublicKey) return false
if (peerNickname != other.peerNickname) return false
if (isFavorite != other.isFavorite) return false
if (theyFavoritedUs != other.theyFavoritedUs) return false
return true
}
override fun hashCode(): Int {
var result = peerNoisePublicKey.contentHashCode()
result = 31 * result + (peerNostrPublicKey?.hashCode() ?: 0)
@@ -54,20 +55,21 @@ interface FavoritesChangeListener {
/**
* Manages favorites with Noise↔Nostr mapping
* Singleton pattern matching iOS implementation
* Singleton pattern matching iOS implementation.
*/
class FavoritesPersistenceService private constructor(private val context: Context) {
companion object {
private const val TAG = "FavoritesPersistenceService"
private const val FAVORITES_KEY = "favorite_relationships"
private const val FAVORITES_KEY = "favorite_relationships" // noiseHex -> relationship
private const val PEERID_INDEX_KEY = "favorite_peerid_index" // peerID(16-hex) -> npub
@Volatile
private var INSTANCE: FavoritesPersistenceService? = null
val shared: FavoritesPersistenceService
get() = INSTANCE ?: throw IllegalStateException("FavoritesPersistenceService not initialized")
fun initialize(context: Context) {
if (INSTANCE == null) {
synchronized(this) {
@@ -78,46 +80,40 @@ class FavoritesPersistenceService private constructor(private val context: Conte
}
}
}
private val stateManager = SecureIdentityStateManager(context)
private val gson = Gson()
private val favorites = mutableMapOf<String, FavoriteRelationship>() // noiseKeyHex -> relationship
private val favorites = mutableMapOf<String, FavoriteRelationship>() // noiseHex -> relationship
// NEW: Index by current mesh peerID (16-hex) for direct lookup when sending Nostr DMs from mesh context
private val peerIdIndex = mutableMapOf<String, String>() // peerID (lowercase 16-hex) -> npub
private val listeners = mutableListOf<FavoritesChangeListener>()
init {
loadFavorites()
loadPeerIdIndex()
}
/**
* Get favorite status for Noise public key
*/
/** Get favorite status for Noise public key */
fun getFavoriteStatus(noisePublicKey: ByteArray): FavoriteRelationship? {
val keyHex = noisePublicKey.joinToString("") { "%02x".format(it) }
return favorites[keyHex]
}
/**
* Get favorite status for 16-hex peerID
*/
/** Get favorite status for 16-hex peerID (by noiseHex prefix match) */
fun getFavoriteStatus(peerID: String): FavoriteRelationship? {
// For 16-hex peerIDs, we need to find the corresponding full Noise key
// This is a simplified lookup - in practice you'd use fingerprint matching
val pid = peerID.lowercase()
for ((_, relationship) in favorites) {
val noiseKeyHex = relationship.peerNoisePublicKey.joinToString("") { "%02x".format(it) }
if (noiseKeyHex.startsWith(peerID)) {
return relationship
}
if (noiseKeyHex.startsWith(pid)) return relationship
}
return null
}
/**
* Update Nostr public key for a peer
*/
/** Update Nostr public key for a peer (indexed by Noise key) */
fun updateNostrPublicKey(noisePublicKey: ByteArray, nostrPubkey: String) {
val keyHex = noisePublicKey.joinToString("") { "%02x".format(it) }
val existing = favorites[keyHex]
if (existing != null) {
val updated = existing.copy(
peerNostrPublicKey = nostrPubkey,
@@ -125,7 +121,6 @@ class FavoritesPersistenceService private constructor(private val context: Conte
)
favorites[keyHex] = updated
} else {
// Create new relationship
val relationship = FavoriteRelationship(
peerNoisePublicKey = noisePublicKey,
peerNostrPublicKey = nostrPubkey,
@@ -137,19 +132,54 @@ class FavoritesPersistenceService private constructor(private val context: Conte
)
favorites[keyHex] = relationship
}
saveFavorites()
notifyChanged(keyHex)
Log.d(TAG, "Updated Nostr pubkey association for ${keyHex.take(16)}...")
}
/**
* Update favorite status
*/
/** NEW: Update Nostr pubkey for specific mesh peerID (16-hex). */
fun updateNostrPublicKeyForPeerID(peerID: String, nostrPubkey: String) {
val pid = peerID.lowercase()
if (pid.length == 16 && pid.matches(Regex("^[0-9a-f]+$"))) {
peerIdIndex[pid] = nostrPubkey
savePeerIdIndex()
Log.d(TAG, "Indexed npub for peerID ${pid.take(8)}")
} else {
Log.w(TAG, "updateNostrPublicKeyForPeerID called with non-16hex peerID: $peerID")
}
}
/** NEW: Resolve Nostr pubkey via current peerID mapping (fast path). */
fun findNostrPubkeyForPeerID(peerID: String): String? {
return peerIdIndex[peerID.lowercase()]
}
/** NEW: Resolve peerID (16-hex) for a given Nostr pubkey (npub or hex). */
fun findPeerIDForNostrPubkey(nostrPubkey: String): String? {
// First, try direct match in peerIdIndex (values are stored as npub strings)
peerIdIndex.entries.firstOrNull { it.value.equals(nostrPubkey, ignoreCase = true) }?.let { return it.key }
// Attempt legacy mapping via favorites Noise key association
val targetHex = normalizeNostrKeyToHex(nostrPubkey)
if (targetHex != null) {
// Find relationship with matching nostr pubkey (normalized to hex) and then try to map to current peerID via noise key prefix
val rel = favorites.values.firstOrNull { it.peerNostrPublicKey?.let { stored -> normalizeNostrKeyToHex(stored) } == targetHex }
if (rel != null) {
val noiseHex = rel.peerNoisePublicKey.joinToString("") { "%02x".format(it) }
// Return 16-hex prefix as best-effort if no explicit mapping exists
return noiseHex.take(16)
}
}
return null
}
/** Update favorite status */
fun updateFavoriteStatus(noisePublicKey: ByteArray, nickname: String, isFavorite: Boolean) {
val keyHex = noisePublicKey.joinToString("") { "%02x".format(it) }
val existing = favorites[keyHex]
val updated = if (existing != null) {
existing.copy(
peerNickname = nickname,
@@ -168,21 +198,19 @@ class FavoritesPersistenceService private constructor(private val context: Conte
lastUpdated = Date()
)
}
favorites[keyHex] = updated
saveFavorites()
notifyChanged(keyHex)
Log.d(TAG, "Updated favorite status for $nickname: $isFavorite")
}
/**
* Update peer favorited us status
*/
/** Update peer favorited-us flag */
fun updatePeerFavoritedUs(noisePublicKey: ByteArray, theyFavoritedUs: Boolean) {
val keyHex = noisePublicKey.joinToString("") { "%02x".format(it) }
val existing = favorites[keyHex]
if (existing != null) {
val updated = existing.copy(
theyFavoritedUs = theyFavoritedUs,
@@ -191,101 +219,98 @@ class FavoritesPersistenceService private constructor(private val context: Conte
favorites[keyHex] = updated
saveFavorites()
notifyChanged(keyHex)
Log.d(TAG, "Updated peer favorited us for ${keyHex.take(16)}...: $theyFavoritedUs")
}
}
/**
* Get all mutual favorites
*/
fun getMutualFavorites(): List<FavoriteRelationship> {
return favorites.values.filter { it.isMutual }
}
/**
* Get all favorites we have
*/
fun getOurFavorites(): List<FavoriteRelationship> {
return favorites.values.filter { it.isFavorite }
}
/**
* Clear all favorites
*/
fun getMutualFavorites(): List<FavoriteRelationship> = favorites.values.filter { it.isMutual }
fun getOurFavorites(): List<FavoriteRelationship> = favorites.values.filter { it.isFavorite }
fun clearAllFavorites() {
favorites.clear()
saveFavorites()
peerIdIndex.clear()
savePeerIdIndex()
Log.i(TAG, "Cleared all favorites")
notifyAllCleared()
}
/**
* Find Noise key by Nostr pubkey
*/
/** Find Noise key by Nostr pubkey */
fun findNoiseKey(forNostrPubkey: String): ByteArray? {
val targetHex = normalizeNostrKeyToHex(forNostrPubkey) ?: return null
return favorites.values.firstOrNull { rel ->
rel.peerNostrPublicKey?.let { stored ->
normalizeNostrKeyToHex(stored)
} == targetHex
rel.peerNostrPublicKey?.let { stored -> normalizeNostrKeyToHex(stored) } == targetHex
}?.peerNoisePublicKey
}
/**
* Find Nostr pubkey by Noise key
*/
/** Find Nostr pubkey by Noise key */
fun findNostrPubkey(forNoiseKey: ByteArray): String? {
val keyHex = forNoiseKey.joinToString("") { "%02x".format(it) }
return favorites[keyHex]?.peerNostrPublicKey
}
// MARK: - Private Methods
// MARK: - Persistence
private fun loadFavorites() {
try {
// Use public methods instead of reflection to access encrypted preferences
val favoritesJson = stateManager.getSecureValue(FAVORITES_KEY)
if (favoritesJson != null) {
val type = object : TypeToken<Map<String, FavoriteRelationshipData>>() {}.type
val data: Map<String, FavoriteRelationshipData> = gson.fromJson(favoritesJson, type)
favorites.clear()
data.forEach { (key, relationshipData) ->
val relationship = relationshipData.toFavoriteRelationship()
favorites[key] = relationship
favorites[key] = relationshipData.toFavoriteRelationship()
}
Log.d(TAG, "Loaded ${favorites.size} favorite relationships")
}
} catch (e: Exception) {
Log.e(TAG, "Failed to load favorites: ${e.message}")
}
}
private fun saveFavorites() {
try {
// Convert to serializable format
val data = favorites.mapValues { (_, relationship) ->
FavoriteRelationshipData.fromFavoriteRelationship(relationship)
}
val favoritesJson = gson.toJson(data)
// Use public methods instead of reflection to access encrypted preferences
stateManager.storeSecureValue(FAVORITES_KEY, favoritesJson)
Log.d(TAG, "Saved ${favorites.size} favorite relationships")
} catch (e: Exception) {
Log.e(TAG, "Failed to save favorites: ${e.message}")
}
}
private fun loadPeerIdIndex() {
try {
val json = stateManager.getSecureValue(PEERID_INDEX_KEY)
if (json != null) {
val type = object : TypeToken<Map<String, String>>() {}.type
val data: Map<String, String> = gson.fromJson(json, type)
peerIdIndex.clear()
peerIdIndex.putAll(data)
Log.d(TAG, "Loaded ${peerIdIndex.size} peerID→npub mappings")
}
} catch (e: Exception) {
Log.e(TAG, "Failed to load peerID index: ${e.message}")
}
}
private fun savePeerIdIndex() {
try {
val json = gson.toJson(peerIdIndex)
stateManager.storeSecureValue(PEERID_INDEX_KEY, json)
Log.d(TAG, "Saved ${peerIdIndex.size} peerID→npub mappings")
} catch (e: Exception) {
Log.e(TAG, "Failed to save peerID index: ${e.message}")
}
}
// MARK: - Listeners
fun addListener(listener: FavoritesChangeListener) {
synchronized(listeners) {
if (!listeners.contains(listener)) listeners.add(listener)
}
synchronized(listeners) { if (!listeners.contains(listener)) listeners.add(listener) }
}
fun removeListener(listener: FavoritesChangeListener) {
synchronized(listeners) { listeners.remove(listener) }
@@ -299,26 +324,16 @@ class FavoritesPersistenceService private constructor(private val context: Conte
snapshot.forEach { runCatching { it.onAllCleared() } }
}
/**
* Normalize a Nostr public key string (npub bech32 or hex) to lowercase hex for comparison
*/
private fun normalizeNostrKeyToHex(value: String): String? {
return try {
if (value.startsWith("npub1")) {
val (hrp, data) = com.bitchat.android.nostr.Bech32.decode(value)
if (hrp != "npub") return null
data.joinToString("") { "%02x".format(it) }
} else {
// Assume hex
value.lowercase()
}
} catch (_: Exception) { null }
}
/** Normalize a Nostr public key string (npub bech32 or hex) to lowercase hex */
private fun normalizeNostrKeyToHex(value: String): String? = try {
if (value.startsWith("npub1")) {
val (hrp, data) = com.bitchat.android.nostr.Bech32.decode(value)
if (hrp != "npub") null else data.joinToString("") { "%02x".format(it) }
} else value.lowercase()
} catch (_: Exception) { null }
}
/**
* Serializable data class for JSON storage
*/
/** Serializable data for JSON storage */
private data class FavoriteRelationshipData(
val peerNoisePublicKeyHex: String,
val peerNostrPublicKey: String?,
@@ -341,7 +356,7 @@ private data class FavoriteRelationshipData(
)
}
}
fun toFavoriteRelationship(): FavoriteRelationship {
val noiseKeyBytes = peerNoisePublicKeyHex.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
return FavoriteRelationship(
@@ -284,6 +284,13 @@ class BluetoothMeshService(private val context: Context) {
// Store fingerprint for the peer via centralized fingerprint manager
val fingerprint = peerManager.storeFingerprintForPeer(newPeerID, publicKey)
// Index existing Nostr mapping by the new peerID if we have it
try {
com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNostrPubkey(publicKey)?.let { npub ->
com.bitchat.android.favorites.FavoritesPersistenceService.shared.updateNostrPublicKeyForPeerID(newPeerID, npub)
}
} catch (_: Exception) { }
// If there was a previous peer ID, remove it to avoid duplicates
previousPeerID?.let { oldPeerID ->
@@ -543,25 +550,13 @@ class BluetoothMeshService(private val context: Context) {
*/
fun sendPrivateMessage(content: String, recipientPeerID: String, recipientNickname: String, messageID: String? = null) {
if (content.isEmpty() || recipientPeerID.isEmpty()) return
if (!recipientPeerID.startsWith("nostr_") && recipientNickname.isEmpty()) return
if (recipientNickname.isEmpty()) return
serviceScope.launch {
val finalMessageID = messageID ?: java.util.UUID.randomUUID().toString()
Log.d(TAG, "📨 Sending PM to $recipientPeerID: ${content.take(30)}...")
// Check if this is a Nostr contact (geohash DM)
if (recipientPeerID.startsWith("nostr_")) {
// Get NostrGeohashService instance and send via Nostr
try {
val nostrGeohashService = com.bitchat.android.nostr.NostrGeohashService.getInstance(context.applicationContext as android.app.Application)
nostrGeohashService.sendNostrGeohashDM(content, recipientPeerID, finalMessageID, myPeerID)
} catch (e: Exception) {
Log.e(TAG, "Failed to send Nostr geohash DM: ${e.message}")
}
return@launch
}
// Check if we have an established Noise session
if (encryptionService.hasEstablishedSession(recipientPeerID)) {
try {
@@ -629,15 +624,14 @@ class BluetoothMeshService(private val context: Context) {
serviceScope.launch {
Log.d(TAG, "📖 Sending read receipt for message $messageID to $recipientPeerID")
// Check if this is a Nostr contact (geohash DM)
if (recipientPeerID.startsWith("nostr_")) {
// Get NostrGeohashService instance and send read receipt via Nostr
try {
val nostrGeohashService = com.bitchat.android.nostr.NostrGeohashService.getInstance(context.applicationContext as android.app.Application)
nostrGeohashService.sendNostrGeohashReadReceipt(messageID, recipientPeerID, myPeerID)
} catch (e: Exception) {
Log.e(TAG, "Failed to send Nostr geohash read receipt: ${e.message}")
}
// Route geohash read receipts via MessageRouter instead of here
val geo = runCatching { com.bitchat.android.services.MessageRouter.tryGetInstance() }.getOrNull()
val isGeoAlias = try {
val map = com.bitchat.android.nostr.GeohashAliasRegistry.snapshot()
map.containsKey(recipientPeerID)
} catch (_: Exception) { false }
if (isGeoAlias && geo != null) {
geo.sendReadReceipt(com.bitchat.android.model.ReadReceipt(messageID), recipientPeerID)
return@launch
}
@@ -459,7 +459,9 @@ class MessageHandler(private val myPeerID: String) {
if (noiseKey != null) {
com.bitchat.android.favorites.FavoritesPersistenceService.shared.updatePeerFavoritedUs(noiseKey, isFavorite)
if (npub != null) {
// Index by noise key and current mesh peerID for fast Nostr routing
com.bitchat.android.favorites.FavoritesPersistenceService.shared.updateNostrPublicKey(noiseKey, npub)
com.bitchat.android.favorites.FavoritesPersistenceService.shared.updateNostrPublicKeyForPeerID(fromPeerID, npub)
}
// Determine iOS-style guidance text
@@ -344,7 +344,8 @@ object TorManager {
bindRetryAttempts = 0
startInactivityMonitoring()
}
s.contains("AMEx: state changed to Running", ignoreCase = true) -> {
//s.contains("AMEx: state changed to Running", ignoreCase = true) -> {
s.contains("We have found that guard [scrubbed] is usable.", ignoreCase = true) -> {
// If we already saw Sufficiently bootstrapped, mark as RUNNING and ready.
val bp = if (_status.value.bootstrapPercent >= 100) 100 else 100 // treat Running as ready
_status.value = _status.value.copy(state = TorState.RUNNING, bootstrapPercent = bp, running = true)
@@ -0,0 +1,24 @@
package com.bitchat.android.nostr
import java.util.concurrent.ConcurrentHashMap
/**
* GeohashAliasRegistry
* - Global, thread-safe registry for alias->Nostr pubkey mappings (e.g., nostr_<pub16> -> pubkeyHex)
* - Allows non-UI components (e.g., MessageRouter) to resolve geohash DM aliases without depending on UI ViewModels
*/
object GeohashAliasRegistry {
private val map: MutableMap<String, String> = ConcurrentHashMap()
fun put(alias: String, pubkeyHex: String) {
map[alias] = pubkeyHex
}
fun get(alias: String): String? = map[alias]
fun contains(alias: String): Boolean = map.containsKey(alias)
fun snapshot(): Map<String, String> = HashMap(map)
fun clear() { map.clear() }
}
@@ -0,0 +1,99 @@
package com.bitchat.android.nostr
import android.app.Application
import android.util.Log
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.ui.ChatState
import com.bitchat.android.ui.MessageManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.Date
/**
* GeohashMessageHandler
* - Processes kind=20000 Nostr events for geohash channels
* - Updates repository for participants + nicknames
* - Emits messages to MessageManager
*/
class GeohashMessageHandler(
private val application: Application,
private val state: ChatState,
private val messageManager: MessageManager,
private val repo: GeohashRepository,
private val scope: CoroutineScope
) {
companion object { private const val TAG = "GeohashMessageHandler" }
// Simple event deduplication
private val processedIds = ArrayDeque<String>()
private val seen = HashSet<String>()
private val max = 2000
private fun dedupe(id: String): Boolean {
if (seen.contains(id)) return true
seen.add(id)
processedIds.addLast(id)
if (processedIds.size > max) {
val old = processedIds.removeFirst()
seen.remove(old)
}
return false
}
fun onEvent(event: NostrEvent, subscribedGeohash: String) {
scope.launch(Dispatchers.Default) {
try {
if (event.kind != 20000) return@launch
val tagGeo = event.tags.firstOrNull { it.size >= 2 && it[0] == "g" }?.getOrNull(1)
if (tagGeo == null || !tagGeo.equals(subscribedGeohash, true)) return@launch
if (dedupe(event.id)) return@launch
// PoW validation (if enabled)
val pow = PoWPreferenceManager.getCurrentSettings()
if (pow.enabled && pow.difficulty > 0) {
if (!NostrProofOfWork.validateDifficulty(event, pow.difficulty)) return@launch
}
// Blocked users check
if (com.bitchat.android.ui.DataManager(application).isGeohashUserBlocked(event.pubkey)) return@launch
// Update repository (participants, nickname, teleport)
// Update repository on a background-safe path; repository will post updates to LiveData
repo.updateParticipant(subscribedGeohash, event.pubkey, Date(event.createdAt * 1000L))
event.tags.find { it.size >= 2 && it[0] == "n" }?.let { repo.cacheNickname(event.pubkey, it[1]) }
event.tags.find { it.size >= 2 && it[0] == "t" && it[1] == "teleport" }?.let { repo.markTeleported(event.pubkey) }
// Register a geohash DM alias for this participant so MessageRouter can route DMs via Nostr
try {
com.bitchat.android.nostr.GeohashAliasRegistry.put("nostr_${event.pubkey.take(16)}", event.pubkey)
} catch (_: Exception) { }
// Skip our own events for message emission
val my = NostrIdentityBridge.deriveIdentity(subscribedGeohash, application)
if (my.publicKeyHex.equals(event.pubkey, true)) return@launch
val isTeleportPresence = event.tags.any { it.size >= 2 && it[0] == "t" && it[1] == "teleport" } &&
event.content.trim().isEmpty()
if (isTeleportPresence) return@launch
val senderName = repo.displayNameForNostrPubkeyUI(event.pubkey)
val msg = BitchatMessage(
id = event.id,
sender = senderName,
content = event.content,
timestamp = Date(event.createdAt * 1000L),
isRelay = false,
originalSender = repo.displayNameForNostrPubkey(event.pubkey),
senderPeerID = "nostr:${event.pubkey.take(8)}",
mentions = null,
channel = "#$subscribedGeohash",
powDifficulty = try { NostrProofOfWork.calculateDifficulty(event.id).takeIf { it > 0 } } catch (_: Exception) { null }
)
withContext(Dispatchers.Main) { messageManager.addChannelMessage("geo:$subscribedGeohash", msg) }
} catch (e: Exception) {
Log.e(TAG, "onEvent error: ${e.message}")
}
}
}
}
@@ -0,0 +1,218 @@
package com.bitchat.android.nostr
import android.app.Application
import android.util.Log
import androidx.lifecycle.LiveData
import com.bitchat.android.ui.ChatState
import com.bitchat.android.ui.GeoPerson
import java.util.Date
/**
* GeohashRepository
* - Owns geohash participant tracking and nickname caching
* - Maintains lightweight state for geohash-related UI
*/
class GeohashRepository(
private val application: Application,
private val state: ChatState
) {
companion object { private const val TAG = "GeohashRepository" }
// geohash -> (participant pubkeyHex -> lastSeen)
private val geohashParticipants: MutableMap<String, MutableMap<String, Date>> = mutableMapOf()
// pubkeyHex(lowercase) -> nickname (without #hash)
private val geoNicknames: MutableMap<String, String> = mutableMapOf()
// conversation key (e.g., "nostr_<pub16>") -> source geohash it belongs to
private val conversationGeohash: MutableMap<String, String> = mutableMapOf()
fun setConversationGeohash(convKey: String, geohash: String) {
if (geohash.isNotEmpty()) {
conversationGeohash[convKey] = geohash
}
}
fun getConversationGeohash(convKey: String): String? = conversationGeohash[convKey]
fun findPubkeyByNickname(targetNickname: String): String? {
return geoNicknames.entries.firstOrNull { (_, nickname) ->
val base = nickname.split("#").firstOrNull() ?: nickname
base == targetNickname
}?.key
}
// peerID alias -> nostr pubkey mapping for geohash DMs and temp aliases
private val nostrKeyMapping: MutableMap<String, String> = mutableMapOf()
// Current geohash in view
private var currentGeohash: String? = null
fun setCurrentGeohash(geo: String?) { currentGeohash = geo }
fun getCurrentGeohash(): String? = currentGeohash
fun clearAll() {
geohashParticipants.clear()
geoNicknames.clear()
nostrKeyMapping.clear()
state.setGeohashPeople(emptyList())
state.setTeleportedGeo(emptySet())
state.setGeohashParticipantCounts(emptyMap())
currentGeohash = null
}
fun cacheNickname(pubkeyHex: String, nickname: String) {
val lower = pubkeyHex.lowercase()
val previous = geoNicknames[lower]
geoNicknames[lower] = nickname
if (previous != nickname && currentGeohash != null) {
refreshGeohashPeople()
}
}
fun getCachedNickname(pubkeyHex: String): String? = geoNicknames[pubkeyHex.lowercase()]
fun markTeleported(pubkeyHex: String) {
val set = state.getTeleportedGeoValue().toMutableSet()
val key = pubkeyHex.lowercase()
if (!set.contains(key)) {
set.add(key)
// Background safe update
state.postTeleportedGeo(set)
}
}
fun isPersonTeleported(pubkeyHex: String): Boolean {
return state.getTeleportedGeoValue().contains(pubkeyHex.lowercase())
}
fun updateParticipant(geohash: String, participantId: String, lastSeen: Date) {
val participants = geohashParticipants.getOrPut(geohash) { mutableMapOf() }
participants[participantId] = lastSeen
if (currentGeohash == geohash) refreshGeohashPeople()
updateReactiveParticipantCounts()
}
fun geohashParticipantCount(geohash: String): Int {
val cutoff = Date(System.currentTimeMillis() - 5 * 60 * 1000)
val participants = geohashParticipants[geohash] ?: return 0
// prune expired
val it = participants.iterator()
while (it.hasNext()) {
val e = it.next()
if (e.value.before(cutoff)) it.remove()
}
return participants.size
}
fun refreshGeohashPeople() {
val geohash = currentGeohash
if (geohash == null) {
// Use postValue for thread safety - this can be called from background threads
state.postGeohashPeople(emptyList())
return
}
val cutoff = Date(System.currentTimeMillis() - 5 * 60 * 1000)
val participants = geohashParticipants[geohash] ?: mutableMapOf()
// prune expired
val it = participants.iterator()
while (it.hasNext()) {
val e = it.next()
if (e.value.before(cutoff)) it.remove()
}
geohashParticipants[geohash] = participants
val people = participants.map { (pubkeyHex, lastSeen) ->
val base = getCachedNickname(pubkeyHex) ?: "anon"
GeoPerson(
id = pubkeyHex.lowercase(),
displayName = base, // UI can add #hash if necessary
lastSeen = lastSeen
)
}.sortedByDescending { it.lastSeen }
// Use postValue for thread safety - this can be called from background threads
state.postGeohashPeople(people)
}
fun updateReactiveParticipantCounts() {
val cutoff = Date(System.currentTimeMillis() - 5 * 60 * 1000)
val counts = mutableMapOf<String, Int>()
for ((gh, participants) in geohashParticipants) {
val active = participants.values.count { !it.before(cutoff) }
counts[gh] = active
}
// Use postValue for thread safety - this can be called from background threads
state.postGeohashParticipantCounts(counts)
}
fun putNostrKeyMapping(tempKeyOrPeer: String, pubkeyHex: String) {
nostrKeyMapping[tempKeyOrPeer] = pubkeyHex
}
fun getNostrKeyMapping(): Map<String, String> = nostrKeyMapping.toMap()
fun displayNameForNostrPubkey(pubkeyHex: String): String {
val suffix = pubkeyHex.takeLast(4)
val lower = pubkeyHex.lowercase()
// Self nickname if matches current identity of current geohash
val current = currentGeohash
if (current != null) {
try {
val my = NostrIdentityBridge.deriveIdentity(current, application)
if (my.publicKeyHex.equals(lower, true)) {
return "${state.getNicknameValue()}#$suffix"
}
} catch (_: Exception) {}
}
val nick = geoNicknames[lower] ?: "anon"
return "$nick#$suffix"
}
fun displayNameForNostrPubkeyUI(pubkeyHex: String): String {
val lower = pubkeyHex.lowercase()
val suffix = pubkeyHex.takeLast(4)
val current = currentGeohash
val base: String = try {
if (current != null) {
val my = NostrIdentityBridge.deriveIdentity(current, application)
if (my.publicKeyHex.equals(lower, true)) {
state.getNicknameValue() ?: "anon"
} else geoNicknames[lower] ?: "anon"
} else geoNicknames[lower] ?: "anon"
} catch (_: Exception) { geoNicknames[lower] ?: "anon" }
if (current == null) return base
return try {
val cutoff = Date(System.currentTimeMillis() - 5 * 60 * 1000)
val participants = geohashParticipants[current] ?: emptyMap()
var count = 0
for ((k, t) in participants) {
if (t.before(cutoff)) continue
val name = if (k.equals(lower, true)) base else (geoNicknames[k.lowercase()] ?: "anon")
if (name.equals(base, true)) { count++; if (count > 1) break }
}
if (!participants.containsKey(lower)) count += 1
if (count > 1) "$base#$suffix" else base
} catch (_: Exception) { base }
}
/**
* Get display name for any geohash (not just current one) for header titles
*/
fun displayNameForGeohashConversation(pubkeyHex: String, sourceGeohash: String): String {
val lower = pubkeyHex.lowercase()
val suffix = pubkeyHex.takeLast(4)
val base = geoNicknames[lower] ?: "anon"
return try {
val cutoff = Date(System.currentTimeMillis() - 5 * 60 * 1000)
val participants = geohashParticipants[sourceGeohash] ?: emptyMap()
var count = 0
for ((k, t) in participants) {
if (t.before(cutoff)) continue
val name = if (k.equals(lower, true)) base else (geoNicknames[k.lowercase()] ?: "anon")
if (name.equals(base, true)) { count++; if (count > 1) break }
}
if (!participants.containsKey(lower)) count += 1
if (count > 1) "$base#$suffix" else base
} catch (_: Exception) { base }
}
}
@@ -0,0 +1,176 @@
package com.bitchat.android.nostr
import android.app.Application
import android.util.Log
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.DeliveryStatus
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.services.SeenMessageStore
import com.bitchat.android.ui.ChatState
import com.bitchat.android.ui.MeshDelegateHandler
import com.bitchat.android.ui.PrivateChatManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.Date
class NostrDirectMessageHandler(
private val application: Application,
private val state: ChatState,
private val privateChatManager: PrivateChatManager,
private val meshDelegateHandler: MeshDelegateHandler,
private val scope: CoroutineScope,
private val repo: GeohashRepository
) {
companion object { private const val TAG = "NostrDirectMessageHandler" }
private val seenStore by lazy { SeenMessageStore.getInstance(application) }
// Simple event deduplication
private val processedIds = ArrayDeque<String>()
private val seen = HashSet<String>()
private val max = 2000
private fun dedupe(id: String): Boolean {
if (seen.contains(id)) return true
seen.add(id)
processedIds.addLast(id)
if (processedIds.size > max) {
val old = processedIds.removeFirst()
seen.remove(old)
}
return false
}
fun onGiftWrap(giftWrap: NostrEvent, geohash: String, identity: NostrIdentity) {
scope.launch(Dispatchers.Default) {
try {
if (dedupe(giftWrap.id)) return@launch
val messageAge = System.currentTimeMillis() / 1000 - giftWrap.createdAt
if (messageAge > 173700) return@launch // 48 hours + 15 mins
val decryptResult = NostrProtocol.decryptPrivateMessage(giftWrap, identity)
if (decryptResult == null) {
Log.w(TAG, "Failed to decrypt Nostr message")
return@launch
}
val (content, senderPubkey, rumorTimestamp) = decryptResult
if (!content.startsWith("bitchat1:")) return@launch
val base64Content = content.removePrefix("bitchat1:")
val packetData = base64URLDecode(base64Content) ?: return@launch
val packet = BitchatPacket.fromBinaryData(packetData) ?: return@launch
if (packet.type != com.bitchat.android.protocol.MessageType.NOISE_ENCRYPTED.value) return@launch
val noisePayload = com.bitchat.android.model.NoisePayload.decode(packet.payload) ?: return@launch
val messageTimestamp = Date(rumorTimestamp * 1000L)
val convKey = "nostr_${senderPubkey.take(16)}"
repo.putNostrKeyMapping(convKey, senderPubkey)
com.bitchat.android.nostr.GeohashAliasRegistry.put(convKey, senderPubkey)
if (geohash.isNotEmpty()) {
// Remember which geohash this conversation belongs to so we can subscribe on-demand
repo.setConversationGeohash(convKey, geohash)
GeohashConversationRegistry.set(convKey, geohash)
}
// Ensure sender appears in geohash people list even if they haven't posted publicly yet
if (geohash.isNotEmpty()) {
// Cache a best-effort nickname and mark as participant
val cached = repo.getCachedNickname(senderPubkey)
if (cached == null) {
val base = repo.displayNameForNostrPubkeyUI(senderPubkey).substringBefore("#")
repo.cacheNickname(senderPubkey, base)
}
repo.updateParticipant(geohash, senderPubkey, messageTimestamp)
}
val senderNickname = repo.displayNameForNostrPubkeyUI(senderPubkey)
processNoisePayload(noisePayload, convKey, senderNickname, messageTimestamp, senderPubkey, identity)
} catch (e: Exception) {
Log.e(TAG, "onGiftWrap error: ${e.message}")
}
}
}
private suspend fun processNoisePayload(
payload: com.bitchat.android.model.NoisePayload,
convKey: String,
senderNickname: String,
timestamp: Date,
senderPubkey: String,
recipientIdentity: NostrIdentity
) {
when (payload.type) {
com.bitchat.android.model.NoisePayloadType.PRIVATE_MESSAGE -> {
val pm = com.bitchat.android.model.PrivateMessagePacket.decode(payload.data) ?: return
val existingMessages = state.getPrivateChatsValue()[convKey] ?: emptyList()
if (existingMessages.any { it.id == pm.messageID }) return
val message = BitchatMessage(
id = pm.messageID,
sender = senderNickname,
content = pm.content,
timestamp = timestamp,
isRelay = false,
isPrivate = true,
recipientNickname = state.getNicknameValue(),
senderPeerID = convKey,
deliveryStatus = DeliveryStatus.Delivered(to = state.getNicknameValue() ?: "Unknown", at = Date())
)
val isViewing = state.getSelectedPrivateChatPeerValue() == convKey
val suppressUnread = seenStore.hasRead(pm.messageID)
withContext(Dispatchers.Main) {
privateChatManager.handleIncomingPrivateMessage(message, suppressUnread)
}
if (!seenStore.hasDelivered(pm.messageID)) {
val nostrTransport = NostrTransport.getInstance(application)
nostrTransport.sendDeliveryAckGeohash(pm.messageID, senderPubkey, recipientIdentity)
seenStore.markDelivered(pm.messageID)
}
if (isViewing && !suppressUnread) {
val nostrTransport = NostrTransport.getInstance(application)
nostrTransport.sendReadReceiptGeohash(pm.messageID, senderPubkey, recipientIdentity)
seenStore.markRead(pm.messageID)
}
}
com.bitchat.android.model.NoisePayloadType.DELIVERED -> {
val messageId = String(payload.data, Charsets.UTF_8)
withContext(Dispatchers.Main) {
meshDelegateHandler.didReceiveDeliveryAck(messageId, convKey)
}
}
com.bitchat.android.model.NoisePayloadType.READ_RECEIPT -> {
val messageId = String(payload.data, Charsets.UTF_8)
withContext(Dispatchers.Main) {
meshDelegateHandler.didReceiveReadReceipt(messageId, convKey)
}
}
}
}
private fun base64URLDecode(input: String): ByteArray? {
return try {
val padded = input.replace("-", "+")
.replace("_", "/")
.let { str ->
val padding = (4 - str.length % 4) % 4
str + "=".repeat(padding)
}
android.util.Base64.decode(padded, android.util.Base64.DEFAULT)
} catch (e: Exception) {
Log.e(TAG, "Failed to decode base64url: ${e.message}")
null
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,38 @@
package com.bitchat.android.nostr
import android.app.Application
import android.util.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
/**
* NostrSubscriptionManager
* - Encapsulates subscription lifecycle with NostrRelayManager
*/
class NostrSubscriptionManager(
private val application: Application,
private val scope: CoroutineScope
) {
companion object { private const val TAG = "NostrSubscriptionManager" }
private val relayManager get() = NostrRelayManager.getInstance(application)
fun connect() = scope.launch { runCatching { relayManager.connect() }.onFailure { Log.e(TAG, "connect failed: ${it.message}") } }
fun disconnect() = scope.launch { runCatching { relayManager.disconnect() }.onFailure { Log.e(TAG, "disconnect failed: ${it.message}") } }
fun subscribeGiftWraps(pubkey: String, sinceMs: Long, id: String, handler: (NostrEvent) -> Unit) {
scope.launch {
val filter = NostrFilter.giftWrapsFor(pubkey, sinceMs)
relayManager.subscribe(filter, id, handler)
}
}
fun subscribeGeohash(geohash: String, sinceMs: Long, limit: Int, id: String, handler: (NostrEvent) -> Unit) {
scope.launch {
val filter = NostrFilter.geohashEphemeral(geohash, sinceMs, limit)
relayManager.subscribeForGeohash(geohash, filter, id, handler, includeDefaults = false, nRelays = 5)
}
}
fun unsubscribe(id: String) { scope.launch { runCatching { relayManager.unsubscribe(id) } } }
}
@@ -56,8 +56,7 @@ class NostrTransport(
// Resolve favorite by full noise key or by short peerID fallback
var recipientNostrPubkey: String? = null
// Try to resolve from favorites persistence service
// This would need integration with the existing favorites system
// Resolve by peerID first (new peerID→npub index), then fall back to noise key mapping
recipientNostrPubkey = resolveNostrPublicKey(to)
if (recipientNostrPubkey == null) {
@@ -86,13 +85,23 @@ class NostrTransport(
return@launch
}
// Strict: lookup the recipient's current BitChat peer ID using favorites mapping
val recipientPeerIDForEmbed = try {
com.bitchat.android.favorites.FavoritesPersistenceService.shared
.findPeerIDForNostrPubkey(recipientNostrPubkey)
} catch (_: Exception) { null }
if (recipientPeerIDForEmbed.isNullOrBlank()) {
Log.e(TAG, "NostrTransport: no peerID stored for recipient npub; cannot embed PM. npub=${recipientNostrPubkey.take(16)}...")
return@launch
}
val embedded = NostrEmbeddedBitChat.encodePMForNostr(
content = content,
messageID = messageID,
recipientPeerID = to,
recipientPeerID = recipientPeerIDForEmbed,
senderPeerID = senderPeerID
)
if (embedded == null) {
Log.e(TAG, "NostrTransport: failed to embed PM packet")
return@launch
@@ -412,39 +421,58 @@ class NostrTransport(
fun sendPrivateMessageGeohash(
content: String,
toRecipientHex: String,
fromIdentity: NostrIdentity,
messageID: String
messageID: String,
sourceGeohash: String? = null
) {
// Use provided geohash or derive from current location
val geohash = sourceGeohash ?: run {
val selected = try {
com.bitchat.android.geohash.LocationChannelManager.getInstance(context).selectedChannel.value
} catch (_: Exception) { null }
if (selected !is com.bitchat.android.geohash.ChannelID.Location) {
Log.w(TAG, "NostrTransport: cannot send geohash PM - not in a location channel and no geohash provided")
return
}
selected.channel.geohash
}
val fromIdentity = try {
NostrIdentityBridge.deriveIdentity(geohash, context)
} catch (e: Exception) {
Log.e(TAG, "NostrTransport: cannot derive geohash identity for $geohash: ${e.message}")
return
}
transportScope.launch {
try {
if (toRecipientHex.isEmpty()) return@launch
Log.d(TAG, "GeoDM: send PM -> recip=${toRecipientHex.take(8)}... mid=${messageID.take(8)}... from=${fromIdentity.publicKeyHex.take(8)}...")
Log.d(
TAG,
"GeoDM: send PM -> recip=${toRecipientHex.take(8)}... mid=${messageID.take(8)}... from=${fromIdentity.publicKeyHex.take(8)}... geohash=$geohash"
)
// Build embedded BitChat packet without recipient peer ID
val embedded = NostrEmbeddedBitChat.encodePMForNostrNoRecipient(
content = content,
messageID = messageID,
senderPeerID = senderPeerID
)
if (embedded == null) {
) ?: run {
Log.e(TAG, "NostrTransport: failed to embed geohash PM packet")
return@launch
}
val giftWraps = NostrProtocol.createPrivateMessage(
content = embedded,
recipientPubkey = toRecipientHex,
senderIdentity = fromIdentity
)
giftWraps.forEach { event ->
Log.d(TAG, "NostrTransport: sending geohash PM giftWrap id=${event.id.take(16)}...")
NostrRelayManager.registerPendingGiftWrap(event.id)
NostrRelayManager.getInstance(context).sendEvent(event)
}
} catch (e: Exception) {
Log.e(TAG, "Failed to send geohash private message: ${e.message}")
}
@@ -458,14 +486,15 @@ class NostrTransport(
*/
private fun resolveNostrPublicKey(peerID: String): String? {
try {
// Try to resolve from favorites persistence service
// 1) Fast path: direct peerID→npub mapping (mutual favorites after mesh mapping)
com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNostrPubkeyForPeerID(peerID)?.let { return it }
// 2) Legacy path: resolve by noise public key association
val noiseKey = hexStringToByteArray(peerID)
val favoriteStatus = com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(noiseKey)
if (favoriteStatus?.peerNostrPublicKey != null) {
return favoriteStatus.peerNostrPublicKey
}
// Fallback: try with 16-hex peerID lookup
if (favoriteStatus?.peerNostrPublicKey != null) return favoriteStatus.peerNostrPublicKey
// 3) Prefix match on noiseHex from 16-hex peerID
if (peerID.length == 16) {
val fallbackStatus = com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(peerID)
return fallbackStatus?.peerNostrPublicKey
@@ -43,6 +43,7 @@ class MessageRouter private constructor(
// Listener for favorites changes to flush outbox when npub mapping appears/changes
private val favoriteListener = object: com.bitchat.android.favorites.FavoritesChangeListener {
override fun onFavoriteChanged(noiseKeyHex: String) {
flushOutboxFor(noiseKeyHex)
// Also try 16-hex short id commonly used in UI if any client used that
@@ -55,18 +56,34 @@ class MessageRouter private constructor(
}
fun sendPrivate(content: String, toPeerID: String, recipientNickname: String, messageID: String) {
// First: if this is a geohash DM alias (nostr_<pub16>), route via Nostr using global registry
if (com.bitchat.android.nostr.GeohashAliasRegistry.contains(toPeerID)) {
Log.d(TAG, "Routing PM via Nostr (geohash) to alias ${toPeerID.take(12)}… id=${messageID.take(8)}")
val recipientHex = com.bitchat.android.nostr.GeohashAliasRegistry.get(toPeerID)
if (recipientHex != null) {
// Resolve the conversation's source geohash, so we can send from anywhere
val sourceGeohash = com.bitchat.android.nostr.GeohashConversationRegistry.get(toPeerID)
// If repository knows the source geohash, pass it so NostrTransport derives the correct identity
nostr.sendPrivateMessageGeohash(content, recipientHex, messageID, sourceGeohash)
return
}
}
val hasMesh = mesh.getPeerInfo(toPeerID)?.isConnected == true
val hasEstablished = mesh.hasEstablishedSession(toPeerID)
if (hasMesh && hasEstablished) {
Log.d(TAG, "Routing PM via mesh to ${toPeerID.take(8)}id=${messageID.take(8)}")
Log.d(TAG, "Routing PM via mesh to ${toPeerID} msg_id=${messageID.take(8)}")
mesh.sendPrivateMessage(content, toPeerID, recipientNickname, messageID)
} else if (canSendViaNostr(toPeerID)) {
Log.d(TAG, "Routing PM via Nostr to ${toPeerID.take(8)}… id=${messageID.take(8)}")
Log.d(TAG, "Routing PM via Nostr to ${toPeerID.take(32)}msg_id=${messageID.take(8)}")
nostr.sendPrivateMessage(content, toPeerID, recipientNickname, messageID)
} else {
Log.d(TAG, "Queued PM for ${toPeerID.take(8)} (no mesh, no Nostr mapping) id=${messageID.take(8)}")
Log.d(TAG, "Queued PM for ${toPeerID} (no mesh, no Nostr mapping) msg_id=${messageID.take(8)}")
val q = outbox.getOrPut(toPeerID) { mutableListOf() }
q.add(Triple(content, recipientNickname, messageID))
Log.d(TAG, "Initiating noise handshake after queueing PM for ${toPeerID.take(8)}")
mesh.initiateNoiseHandshake(toPeerID)
}
}
@@ -82,7 +99,14 @@ class MessageRouter private constructor(
fun sendDeliveryAck(messageID: String, toPeerID: String) {
// Mesh delivery ACKs are sent by the receiver automatically.
// Only route via Nostr when mesh path isn't available.
// Only route via Nostr when mesh path isn't available or when this is a geohash alias
if (com.bitchat.android.nostr.GeohashAliasRegistry.contains(toPeerID)) {
val recipientHex = com.bitchat.android.nostr.GeohashAliasRegistry.get(toPeerID)
if (recipientHex != null) {
nostr.sendDeliveryAckGeohash(messageID, recipientHex, try { com.bitchat.android.nostr.NostrIdentityBridge.getCurrentNostrIdentity(context)!! } catch (_: Exception) { return })
return
}
}
if (!((mesh.getPeerInfo(toPeerID)?.isConnected == true) && mesh.hasEstablishedSession(toPeerID))) {
nostr.sendDeliveryAck(messageID, toPeerID)
}
@@ -1,5 +1,6 @@
package com.bitchat.android.ui
import android.util.Log
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
@@ -31,6 +32,7 @@ import com.bitchat.android.core.ui.utils.singleOrTripleClickable
* Extracted from ChatScreen.kt for better organization
*/
/**
* Reactive helper to compute favorite state from fingerprint mapping
* This eliminates the need for static isFavorite parameters and makes
@@ -159,7 +161,6 @@ fun PeerCounter(
connectedPeers: List<String>,
joinedChannels: Set<String>,
hasUnreadChannels: Map<String, Int>,
hasUnreadPrivateMessages: Set<String>,
isConnected: Boolean,
selectedLocationChannel: com.bitchat.android.geohash.ChannelID?,
geohashPeople: List<GeoPerson>,
@@ -189,33 +190,6 @@ fun PeerCounter(
verticalAlignment = Alignment.CenterVertically,
modifier = modifier.clickable { onClick() }.padding(end = 8.dp) // Added right margin to match "bitchat" logo spacing
) {
if (hasUnreadChannels.values.any { it > 0 }) {
// Channel icon in a Box to ensure consistent size with other icons
Box(
modifier = Modifier.size(16.dp),
contentAlignment = Alignment.Center
) {
Text(
text = "#",
style = MaterialTheme.typography.bodyMedium,
color = Color(0xFF0080FF),
fontSize = 16.sp
)
}
Spacer(modifier = Modifier.width(6.dp))
}
if (hasUnreadPrivateMessages.isNotEmpty()) {
// Filled mail icon to match sidebar style
Icon(
imageVector = Icons.Filled.Email,
contentDescription = "Unread private messages",
modifier = Modifier.size(16.dp),
tint = Color(0xFFFF9500) // Orange to match private message theme
)
Spacer(modifier = Modifier.width(6.dp))
}
Icon(
imageVector = Icons.Default.Group,
contentDescription = when (selectedLocationChannel) {
@@ -226,6 +200,7 @@ fun PeerCounter(
tint = countColor
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = "$peopleCount",
style = MaterialTheme.typography.bodyMedium,
@@ -290,7 +265,8 @@ fun ChatHeaderContent(
selectedLocationChannel = selectedLocationChannel,
geohashPeople = geohashPeople,
onBackClick = onBackClick,
onToggleFavorite = { viewModel.toggleFavorite(selectedPrivatePeer) }
onToggleFavorite = { viewModel.toggleFavorite(selectedPrivatePeer) },
viewModel = viewModel
)
}
currentChannel != null -> {
@@ -326,7 +302,8 @@ private fun PrivateChatHeader(
selectedLocationChannel: com.bitchat.android.geohash.ChannelID?,
geohashPeople: List<GeoPerson>,
onBackClick: () -> Unit,
onToggleFavorite: () -> Unit
onToggleFavorite: () -> Unit,
viewModel: ChatViewModel
) {
val colorScheme = MaterialTheme.colorScheme
val isNostrDM = peerID.startsWith("nostr_") || peerID.startsWith("nostr:")
@@ -345,12 +322,24 @@ private fun PrivateChatHeader(
// Compute title text: for NIP-17 chats show "#geohash/@username" (iOS parity)
val titleText: String = if (isNostrDM) {
val geohash = (selectedLocationChannel as? com.bitchat.android.geohash.ChannelID.Location)?.channel?.geohash
val shortId = peerID.removePrefix("nostr_").removePrefix("nostr:")
val person = geohashPeople.firstOrNull { it.id.startsWith(shortId, ignoreCase = true) }
val baseName = person?.displayName?.substringBefore('#') ?: peerNicknames[peerID] ?: "unknown"
val geoPart = geohash?.let { "#$it" } ?: "#geohash"
"$geoPart/@$baseName"
// For geohash DMs, get the actual source geohash and proper display name
val (conversationGeohash, baseName) = try {
val repoField = com.bitchat.android.ui.GeohashViewModel::class.java.getDeclaredField("repo")
repoField.isAccessible = true
val repo = repoField.get(viewModel.geohashViewModel) as com.bitchat.android.nostr.GeohashRepository
val gh = repo.getConversationGeohash(peerID) ?: "geohash"
val fullPubkey = com.bitchat.android.nostr.GeohashAliasRegistry.get(peerID) ?: ""
val displayName = if (fullPubkey.isNotEmpty()) {
repo.displayNameForGeohashConversation(fullPubkey, gh)
} else {
peerNicknames[peerID] ?: "unknown"
}
Pair(gh, displayName)
} catch (e: Exception) {
Pair("geohash", peerNicknames[peerID] ?: "unknown")
}
"#$conversationGeohash/@$baseName"
} else {
// Prefer live mesh nickname; fallback to favorites nickname (supports 16-hex), finally short key
peerNicknames[peerID] ?: run {
@@ -563,6 +552,19 @@ private fun MainHeader(
horizontalArrangement = Arrangement.spacedBy(5.dp)
) {
// Unread private messages badge (click to open most recent DM)
if (hasUnreadPrivateMessages.isNotEmpty()) {
// Render icon directly to avoid symbol resolution issues
Icon(
imageVector = Icons.Filled.Email,
contentDescription = "Unread private messages",
modifier = Modifier
.size(16.dp)
.clickable { viewModel.openLatestUnreadPrivateChat() },
tint = Color(0xFFFF9500)
)
}
// Location channels button (matching iOS implementation)
LocationChannelsButton(
viewModel = viewModel,
@@ -582,7 +584,6 @@ private fun MainHeader(
connectedPeers = connectedPeers.filter { it != viewModel.meshService.myPeerID },
joinedChannels = joinedChannels,
hasUnreadChannels = hasUnreadChannels,
hasUnreadPrivateMessages = hasUnreadPrivateMessages,
isConnected = isConnected,
selectedLocationChannel = selectedLocationChannel,
geohashPeople = geohashPeople,
@@ -74,18 +74,17 @@ fun ChatScreen(viewModel: ChatViewModel) {
// Get location channel info for timeline switching
val selectedLocationChannel by viewModel.selectedLocationChannel.observeAsState()
// Determine what messages to show based on current context
// Determine what messages to show based on current context (unified timelines)
val displayMessages = when {
selectedPrivatePeer != null -> privateChats[selectedPrivatePeer] ?: emptyList()
currentChannel != null -> channelMessages[currentChannel] ?: emptyList()
else -> {
val locationChannel = selectedLocationChannel
if (locationChannel is com.bitchat.android.geohash.ChannelID.Location) {
// For geohash channels, get messages from geohash history
val geohash = locationChannel.channel.geohash
viewModel.getGeohashMessages(geohash)
val geokey = "geo:${locationChannel.channel.geohash}"
channelMessages[geokey] ?: emptyList()
} else {
messages // Mesh/public messages
messages // Mesh timeline
}
}
}
@@ -124,6 +124,9 @@ class ChatState {
// Geohash people state (iOS-compatible)
private val _geohashPeople = MutableLiveData<List<GeoPerson>>(emptyList())
val geohashPeople: LiveData<List<GeoPerson>> = _geohashPeople
// For background thread updates by repositories/handlers in their own scopes
val geohashPeopleMutable: MutableLiveData<List<GeoPerson>> get() = _geohashPeople
private val _teleportedGeo = MutableLiveData<Set<String>>(emptySet())
val teleportedGeo: LiveData<Set<String>> = _teleportedGeo
@@ -155,6 +158,16 @@ class ChatState {
fun getSelectedPrivateChatPeerValue() = _selectedPrivateChatPeer.value
fun getUnreadPrivateMessagesValue() = _unreadPrivateMessages.value ?: emptySet()
fun getJoinedChannelsValue() = _joinedChannels.value ?: emptySet()
// Thread-safe posting helpers for background updates
fun postGeohashPeople(people: List<GeoPerson>) {
_geohashPeople.postValue(people)
}
fun postGeohashParticipantCounts(counts: Map<String, Int>) {
_geohashParticipantCounts.postValue(counts)
}
fun getCurrentChannelValue() = _currentChannel.value
fun getChannelMessagesValue() = _channelMessages.value ?: emptyMap()
fun getUnreadChannelMessagesValue() = _unreadChannelMessages.value ?: emptyMap()
@@ -183,6 +196,10 @@ class ChatState {
_connectedPeers.value = peers
}
fun postTeleportedGeo(teleported: Set<String>) {
_teleportedGeo.postValue(teleported)
}
fun setNickname(nickname: String) {
_nickname.value = nickname
}
@@ -10,7 +10,8 @@ import com.bitchat.android.mesh.BluetoothMeshDelegate
import com.bitchat.android.mesh.BluetoothMeshService
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.nostr.NostrGeohashService
import kotlinx.coroutines.launch
import com.bitchat.android.util.NotificationIntervalManager
import kotlinx.coroutines.delay
@@ -68,18 +69,20 @@ class ChatViewModel(
getMeshService = { meshService }
)
// Nostr and Geohash service - initialize singleton
private val nostrGeohashService = NostrGeohashService.initialize(
// New Geohash architecture ViewModel (replaces God object service usage in UI path)
val geohashViewModel = GeohashViewModel(
application = application,
state = state,
messageManager = messageManager,
privateChatManager = privateChatManager,
meshDelegateHandler = meshDelegateHandler,
coroutineScope = viewModelScope,
dataManager = dataManager,
notificationManager = notificationManager
)
// Expose state through LiveData (maintaining the same interface)
val messages: LiveData<List<BitchatMessage>> = state.messages
val connectedPeers: LiveData<List<String>> = state.connectedPeers
@@ -168,14 +171,12 @@ class ChatViewModel(
}
}
// Initialize location channel state
nostrGeohashService.initializeLocationChannelState()
// Initialize new geohash architecture
geohashViewModel.initialize()
// Initialize favorites persistence service
com.bitchat.android.favorites.FavoritesPersistenceService.initialize(getApplication())
// Initialize Nostr integration
nostrGeohashService.initializeNostrIntegration()
// Ensure NostrTransport knows our mesh peer ID for embedded packets
try {
@@ -213,6 +214,46 @@ class ChatViewModel(
meshService.sendBroadcastAnnounce()
}
/**
* Ensure Nostr DM subscription for a geohash conversation key if known
* Minimal-change approach: reflectively access GeohashViewModel internals to reuse pipeline
*/
private fun ensureGeohashDMSubscriptionIfNeeded(convKey: String) {
try {
val repoField = GeohashViewModel::class.java.getDeclaredField("repo")
repoField.isAccessible = true
val repo = repoField.get(geohashViewModel) as com.bitchat.android.nostr.GeohashRepository
val gh = repo.getConversationGeohash(convKey)
if (!gh.isNullOrEmpty()) {
val subMgrField = GeohashViewModel::class.java.getDeclaredField("subscriptionManager")
subMgrField.isAccessible = true
val subMgr = subMgrField.get(geohashViewModel) as com.bitchat.android.nostr.NostrSubscriptionManager
val identity = com.bitchat.android.nostr.NostrIdentityBridge.deriveIdentity(gh, getApplication())
val subId = "geo-dm-$gh"
val currentDmSubField = GeohashViewModel::class.java.getDeclaredField("currentDmSubId")
currentDmSubField.isAccessible = true
val currentId = currentDmSubField.get(geohashViewModel) as String?
if (currentId != subId) {
(currentId)?.let { subMgr.unsubscribe(it) }
currentDmSubField.set(geohashViewModel, subId)
subMgr.subscribeGiftWraps(
pubkey = identity.publicKeyHex,
sinceMs = System.currentTimeMillis() - 172800000L,
id = subId,
handler = { event ->
val dmHandlerField = GeohashViewModel::class.java.getDeclaredField("dmHandler")
dmHandlerField.isAccessible = true
val dmHandler = dmHandlerField.get(geohashViewModel) as com.bitchat.android.nostr.NostrDirectMessageHandler
dmHandler.onGiftWrap(event, gh, identity)
}
)
}
}
} catch (e: Exception) {
Log.w(TAG, "ensureGeohashDMSubscriptionIfNeeded failed: ${e.message}")
}
}
// MARK: - Channel Management (delegated)
fun joinChannel(channel: String, password: String? = null): Boolean {
@@ -231,6 +272,11 @@ class ChatViewModel(
// MARK: - Private Chat Management (delegated)
fun startPrivateChat(peerID: String) {
// For geohash conversation keys, ensure DM subscription is active
if (peerID.startsWith("nostr_")) {
ensureGeohashDMSubscriptionIfNeeded(peerID)
}
val success = privateChatManager.startPrivateChat(peerID, meshService)
if (success) {
// Notify notification manager about current private chat
@@ -258,6 +304,66 @@ class ChatViewModel(
// Clear mesh mention notifications since user is now back in mesh chat
clearMeshMentionNotifications()
}
// MARK: - Open Latest Unread Private Chat
fun openLatestUnreadPrivateChat() {
try {
val unreadKeys = state.getUnreadPrivateMessagesValue()
if (unreadKeys.isEmpty()) return
val me = state.getNicknameValue() ?: meshService.myPeerID
val chats = state.getPrivateChatsValue()
// Pick the latest incoming message among unread conversations
var bestKey: String? = null
var bestTime: Long = Long.MIN_VALUE
unreadKeys.forEach { key ->
val list = chats[key]
if (!list.isNullOrEmpty()) {
// Prefer the latest incoming message (sender != me), fallback to last message
val latestIncoming = list.lastOrNull { it.sender != me }
val candidateTime = (latestIncoming ?: list.last()).timestamp.time
if (candidateTime > bestTime) {
bestTime = candidateTime
bestKey = key
}
}
}
val targetKey = bestKey ?: unreadKeys.firstOrNull() ?: return
val openPeer: String = if (targetKey.startsWith("nostr_")) {
// Use the exact conversation key for geohash DMs and ensure DM subscription
ensureGeohashDMSubscriptionIfNeeded(targetKey)
targetKey
} else {
// Resolve to a canonical mesh peer if needed
val canonical = com.bitchat.android.services.ConversationAliasResolver.resolveCanonicalPeerID(
selectedPeerID = targetKey,
connectedPeers = state.getConnectedPeersValue(),
meshNoiseKeyForPeer = { pid -> meshService.getPeerInfo(pid)?.noisePublicKey },
meshHasPeer = { pid -> meshService.getPeerInfo(pid)?.isConnected == true },
nostrPubHexForAlias = { alias -> com.bitchat.android.nostr.GeohashAliasRegistry.get(alias) },
findNoiseKeyForNostr = { key -> com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNoiseKey(key) }
)
canonical ?: targetKey
}
startPrivateChat(openPeer)
// If sidebar visible, hide it to focus on the private chat
if (state.getShowSidebarValue()) {
state.setShowSidebar(false)
}
} catch (e: Exception) {
Log.w(TAG, "openLatestUnreadPrivateChat failed: ${e.message}")
}
}
// END - Open Latest Unread Private Chat
// MARK: - Message Sending
@@ -270,7 +376,7 @@ class ChatViewModel(
commandProcessor.processCommand(content, meshService, meshService.myPeerID, { messageContent, mentions, channel ->
if (selectedLocationForCommand is com.bitchat.android.geohash.ChannelID.Location) {
// Route command-generated public messages via Nostr in geohash channels
nostrGeohashService.sendGeohashMessage(
geohashViewModel.sendGeohashMessage(
messageContent,
selectedLocationForCommand.channel,
meshService.myPeerID,
@@ -298,7 +404,7 @@ class ChatViewModel(
connectedPeers = state.getConnectedPeersValue(),
meshNoiseKeyForPeer = { pid -> meshService.getPeerInfo(pid)?.noisePublicKey },
meshHasPeer = { pid -> meshService.getPeerInfo(pid)?.isConnected == true },
nostrPubHexForAlias = { alias -> nostrGeohashService.getNostrKeyMapping()[alias] },
nostrPubHexForAlias = { alias -> com.bitchat.android.nostr.GeohashAliasRegistry.get(alias) },
findNoiseKeyForNostr = { key -> com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNoiseKey(key) }
).also { canonical ->
if (canonical != state.getSelectedPrivateChatPeerValue()) {
@@ -323,7 +429,7 @@ class ChatViewModel(
val selectedLocationChannel = state.selectedLocationChannel.value
if (selectedLocationChannel is com.bitchat.android.geohash.ChannelID.Location) {
// Send to geohash channel via Nostr ephemeral event
nostrGeohashService.sendGeohashMessage(content, selectedLocationChannel.channel, meshService.myPeerID, state.getNicknameValue())
geohashViewModel.sendGeohashMessage(content, selectedLocationChannel.channel, meshService.myPeerID, state.getNicknameValue())
} else {
// Send public/channel message via mesh
val message = BitchatMessage(
@@ -376,17 +482,36 @@ class ChatViewModel(
Log.d("ChatViewModel", "toggleFavorite called for peerID: $peerID")
privateChatManager.toggleFavorite(peerID)
// Persist relationship in FavoritesPersistenceService when we have Noise key
// Persist relationship in FavoritesPersistenceService
try {
var noiseKey: ByteArray? = null
var nickname: String = meshService.getPeerNicknames()[peerID] ?: peerID
// Case 1: Live mesh peer with known info
val peerInfo = meshService.getPeerInfo(peerID)
val noiseKey = peerInfo?.noisePublicKey
val nickname = peerInfo?.nickname ?: (meshService.getPeerNicknames()[peerID] ?: peerID)
if (peerInfo?.noisePublicKey != null) {
noiseKey = peerInfo.noisePublicKey
nickname = peerInfo.nickname
} else {
// Case 2: Offline favorite entry using 64-hex noise public key as peerID
if (peerID.length == 64 && peerID.matches(Regex("^[0-9a-fA-F]+$"))) {
try {
noiseKey = peerID.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
// Prefer nickname from favorites store if available
val rel = com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(noiseKey!!)
if (rel != null) nickname = rel.peerNickname
} catch (_: Exception) { }
}
}
if (noiseKey != null) {
val isNowFavorite = dataManager.favoritePeers.contains(
com.bitchat.android.mesh.PeerFingerprintManager.getInstance().getFingerprintForPeer(peerID) ?: ""
)
// Determine current favorite state from DataManager using fingerprint
val identityManager = com.bitchat.android.identity.SecureIdentityStateManager(getApplication())
val fingerprint = identityManager.generateFingerprint(noiseKey!!)
val isNowFavorite = dataManager.favoritePeers.contains(fingerprint)
com.bitchat.android.favorites.FavoritesPersistenceService.shared.updateFavoriteStatus(
noisePublicKey = noiseKey,
noisePublicKey = noiseKey!!,
nickname = nickname,
isFavorite = isNowFavorite
)
@@ -596,7 +721,7 @@ class ChatViewModel(
// Clear Nostr/geohash state, keys, connections, and reinitialize from scratch
try {
nostrGeohashService.panicResetNostrAndGeohash()
geohashViewModel.panicReset()
} catch (e: Exception) {
Log.e(TAG, "Failed to reset Nostr/geohash: ${e.message}")
}
@@ -638,10 +763,20 @@ class ChatViewModel(
try {
val identityManager = com.bitchat.android.identity.SecureIdentityStateManager(getApplication())
identityManager.clearIdentityData()
Log.d(TAG, "✅ Cleared secure identity state")
// Also clear secure values used by FavoritesPersistenceService (favorites + peerID index)
try {
identityManager.clearSecureValues("favorite_relationships", "favorite_peerid_index")
} catch (_: Exception) { }
Log.d(TAG, "✅ Cleared secure identity state and secure favorites store")
} catch (e: Exception) {
Log.d(TAG, "SecureIdentityStateManager not available or already cleared: ${e.message}")
}
// Clear FavoritesPersistenceService persistent relationships
try {
com.bitchat.android.favorites.FavoritesPersistenceService.shared.clearAllFavorites()
Log.d(TAG, "✅ Cleared FavoritesPersistenceService relationships")
} catch (_: Exception) { }
Log.d(TAG, "✅ Cleared all cryptographic data")
} catch (e: Exception) {
@@ -649,59 +784,52 @@ class ChatViewModel(
}
}
/**
* Get messages for a specific geohash timeline
*/
fun getGeohashMessages(geohash: String): List<BitchatMessage> {
return nostrGeohashService.getGeohashMessages(geohash)
}
/**
* Get participant count for a specific geohash (5-minute activity window)
*/
fun geohashParticipantCount(geohash: String): Int {
return nostrGeohashService.geohashParticipantCount(geohash)
return geohashViewModel.geohashParticipantCount(geohash)
}
/**
* Begin sampling multiple geohashes for participant activity
*/
fun beginGeohashSampling(geohashes: List<String>) {
nostrGeohashService.beginGeohashSampling(geohashes)
geohashViewModel.beginGeohashSampling(geohashes)
}
/**
* End geohash sampling
*/
fun endGeohashSampling() {
nostrGeohashService.endGeohashSampling()
// No-op in refactored architecture; sampling subscriptions are short-lived
}
/**
* Check if a geohash person is teleported (iOS-compatible)
*/
fun isPersonTeleported(pubkeyHex: String): Boolean {
return nostrGeohashService.isPersonTeleported(pubkeyHex)
return geohashViewModel.isPersonTeleported(pubkeyHex)
}
/**
* Start geohash DM with pubkey hex (iOS-compatible)
*/
fun startGeohashDM(pubkeyHex: String) {
nostrGeohashService.startGeohashDM(pubkeyHex) { convKey ->
geohashViewModel.startGeohashDM(pubkeyHex) { convKey ->
startPrivateChat(convKey)
}
}
fun selectLocationChannel(channel: com.bitchat.android.geohash.ChannelID) {
nostrGeohashService.selectLocationChannel(channel)
geohashViewModel.selectLocationChannel(channel)
}
/**
* Block a user in geohash channels by their nickname
*/
fun blockUserInGeohash(targetNickname: String) {
nostrGeohashService.blockUserInGeohash(targetNickname)
geohashViewModel.blockUserInGeohash(targetNickname)
}
// MARK: - Navigation Management
@@ -774,6 +902,6 @@ class ChatViewModel(
* Get consistent color for a Nostr pubkey (iOS-compatible)
*/
fun colorForNostrPubkey(pubkeyHex: String, isDark: Boolean): androidx.compose.ui.graphics.Color {
return nostrGeohashService.colorForNostrPubkey(pubkeyHex, isDark)
return geohashViewModel.colorForNostrPubkey(pubkeyHex, isDark)
}
}
@@ -293,7 +293,7 @@ class CommandProcessor(
val actionMessage = "* ${state.getNicknameValue() ?: "someone"} $verb $targetName $object_ *"
// If we're in a geohash location channel, don't add a local echo here.
// NostrGeohashService.sendGeohashMessage() will add the local echo with proper metadata.
// GeohashViewModel.sendGeohashMessage() will add the local echo with proper metadata.
val isInLocationChannel = state.selectedLocationChannel.value is com.bitchat.android.geohash.ChannelID.Location
// Send as regular message
@@ -0,0 +1,283 @@
package com.bitchat.android.ui
import android.annotation.SuppressLint
import android.app.Activity
import android.content.Intent
import android.content.res.Configuration
import android.os.Bundle
import android.view.ViewGroup
import android.webkit.JavascriptInterface
import android.webkit.WebChromeClient
import android.webkit.WebSettings
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Remove
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.view.updateLayoutParams
import com.bitchat.android.geohash.Geohash
import com.bitchat.android.geohash.LocationChannelManager
import com.bitchat.android.ui.theme.BASE_FONT_SIZE
@OptIn(ExperimentalMaterial3Api::class)
class GeohashPickerActivity : ComponentActivity() {
companion object {
const val EXTRA_INITIAL_GEOHASH = "initial_geohash"
const val EXTRA_RESULT_GEOHASH = "result_geohash"
}
@SuppressLint("SetJavaScriptEnabled")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val initialGeohash = intent.getStringExtra(EXTRA_INITIAL_GEOHASH)?.trim()?.lowercase()
var geohashToFocus: String? = null
var (initLat, initLon) = 0.0 to 0.0
if (!initialGeohash.isNullOrEmpty()) {
geohashToFocus = initialGeohash
try {
val (lat, lon) = Geohash.decodeToCenter(initialGeohash)
initLat = lat
initLon = lon
} catch (_: Throwable) {}
} else {
// If no initial geohash, try to use the user's coarsest location
val locationManager = LocationChannelManager.getInstance(applicationContext)
val channels = locationManager.availableChannels.value
if (!channels.isNullOrEmpty()) {
val coarsestChannel = channels.minByOrNull { it.geohash.length }
if (coarsestChannel != null) {
geohashToFocus = coarsestChannel.geohash
try {
val (lat, lon) = Geohash.decodeToCenter(coarsestChannel.geohash)
initLat = lat
initLon = lon
} catch (_: Throwable) {}
}
}
}
val initialPrecision = geohashToFocus?.length ?: 5
setContent {
MaterialTheme {
var currentGeohash by remember { mutableStateOf(geohashToFocus ?: "") }
var precision by remember { mutableStateOf(initialPrecision.coerceIn(1, 12)) }
var webViewRef by remember { mutableStateOf<WebView?>(null) }
// iOS system-like colors used across app
val colorScheme = MaterialTheme.colorScheme
val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
val standardGreen = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D)
Scaffold { padding ->
Box(Modifier.fillMaxSize()) {
AndroidView(
factory = { context ->
WebView(context).apply {
settings.javaScriptEnabled = true
settings.domStorageEnabled = true
settings.cacheMode = WebSettings.LOAD_DEFAULT
settings.allowFileAccess = true
settings.allowContentAccess = true
webChromeClient = WebChromeClient()
webViewClient = object : WebViewClient() {
override fun onPageFinished(view: WebView?, url: String?) {
super.onPageFinished(view, url)
// Initialize to last/initial geohash if provided, otherwise center
if (!geohashToFocus.isNullOrEmpty()) {
evaluateJavascript(
"window.focusGeohash('${geohashToFocus}')",
null
)
} else {
evaluateJavascript(
"window.setCenter(${initLat}, ${initLon})",
null
)
}
// Apply theme to map tiles
val nightModeFlags = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK
val theme = if (nightModeFlags == Configuration.UI_MODE_NIGHT_YES) "dark" else "light"
evaluateJavascript("window.setMapTheme('" + theme + "')", null)
}
}
addJavascriptInterface(object {
@JavascriptInterface
fun onGeohashChanged(geohash: String) {
runOnUiThread {
currentGeohash = geohash
}
}
}, "Android")
loadUrl("file:///android_asset/geohash_picker.html")
}
},
modifier = Modifier
.fillMaxSize()
.padding(padding),
update = { webView ->
webViewRef = webView
// ensure it fills parent
webView.updateLayoutParams<ViewGroup.LayoutParams> {
width = ViewGroup.LayoutParams.MATCH_PARENT
height = ViewGroup.LayoutParams.MATCH_PARENT
}
},
onRelease = { webView ->
// Best-effort cleanup to avoid leaks and timers
try { webView.evaluateJavascript("window.cleanup && window.cleanup()", null) } catch (_: Throwable) {}
try { webView.stopLoading() } catch (_: Throwable) {}
try { webView.clearHistory() } catch (_: Throwable) {}
try { webView.clearCache(true) } catch (_: Throwable) {}
try { webView.loadUrl("about:blank") } catch (_: Throwable) {}
try { webView.removeAllViews() } catch (_: Throwable) {}
try { webView.destroy() } catch (_: Throwable) {}
}
)
// Floating info pill
Surface(
modifier = Modifier
.align(Alignment.TopCenter)
.padding(top = 20.dp)
.fillMaxWidth(0.75f),
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.85f),
shape = RoundedCornerShape(12.dp),
tonalElevation = 3.dp,
shadowElevation = 6.dp
) {
Text(
text = "pan and zoom to select a geohash",
fontSize = 12.sp,
textAlign = TextAlign.Center,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier
.padding(horizontal = 14.dp, vertical = 10.dp)
)
}
// Floating bottom controls
Column(
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 20.dp, start = 16.dp, end = 16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
// Geohash label (monospace, app style)
Surface(
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.85f),
shape = RoundedCornerShape(12.dp),
tonalElevation = 3.dp,
shadowElevation = 6.dp
) {
Text(
text = if (currentGeohash.isNotEmpty()) "#${currentGeohash}" else "select location",
fontSize = BASE_FONT_SIZE.sp,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier
.padding(horizontal = 14.dp, vertical = 10.dp)
)
}
// Button row
Row(
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Decrease precision
Button(
onClick = {
precision = (precision - 1).coerceAtLeast(1)
webViewRef?.evaluateJavascript("window.setPrecision($precision)", null)
},
colors = ButtonDefaults.buttonColors(
containerColor = standardGreen.copy(alpha = 0.12f),
contentColor = standardGreen
)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Filled.Remove, contentDescription = "Decrease precision")
}
}
// Increase precision
Button(
onClick = {
precision = (precision + 1).coerceAtMost(12)
webViewRef?.evaluateJavascript("window.setPrecision($precision)", null)
},
colors = ButtonDefaults.buttonColors(
containerColor = standardGreen.copy(alpha = 0.12f),
contentColor = standardGreen
)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Filled.Add, contentDescription = "Increase precision")
}
}
// Select button
Button(
onClick = {
webViewRef?.evaluateJavascript("window.getGeohash()") { value ->
val gh = value?.trim('"') ?: currentGeohash
val result = Intent().apply { putExtra(EXTRA_RESULT_GEOHASH, gh) }
setResult(Activity.RESULT_OK, result)
finish()
}
},
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.secondary.copy(alpha = 0.12f),
contentColor = MaterialTheme.colorScheme.onSurface
)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Filled.Check, contentDescription = "Select geohash")
Spacer(Modifier.width(6.dp))
Text(
text = "select",
fontSize = (BASE_FONT_SIZE - 2).sp,
fontFamily = FontFamily.Monospace
)
}
}
}
}
}
}
}
}
}
}
@@ -0,0 +1,259 @@
package com.bitchat.android.ui
import android.app.Application
import android.util.Log
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData
import androidx.lifecycle.viewModelScope
import com.bitchat.android.nostr.GeohashMessageHandler
import com.bitchat.android.nostr.GeohashRepository
import com.bitchat.android.nostr.NostrDirectMessageHandler
import com.bitchat.android.nostr.NostrIdentityBridge
import com.bitchat.android.nostr.NostrProtocol
import com.bitchat.android.nostr.NostrRelayManager
import com.bitchat.android.nostr.NostrSubscriptionManager
import com.bitchat.android.nostr.PoWPreferenceManager
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.util.Date
class GeohashViewModel(
application: Application,
private val state: ChatState,
private val messageManager: MessageManager,
private val privateChatManager: PrivateChatManager,
private val meshDelegateHandler: MeshDelegateHandler,
private val dataManager: DataManager,
private val notificationManager: NotificationManager
) : AndroidViewModel(application) {
companion object { private const val TAG = "GeohashViewModel" }
private val repo = GeohashRepository(application, state)
private val subscriptionManager = NostrSubscriptionManager(application, viewModelScope)
private val geohashMessageHandler = GeohashMessageHandler(application, state, messageManager, repo, viewModelScope)
private val dmHandler = NostrDirectMessageHandler(application, state, privateChatManager, meshDelegateHandler, viewModelScope, repo)
private var currentGeohashSubId: String? = null
private var currentDmSubId: String? = null
private var geoTimer: Job? = null
private var locationChannelManager: com.bitchat.android.geohash.LocationChannelManager? = null
val geohashPeople: LiveData<List<GeoPerson>> = state.geohashPeople
val geohashParticipantCounts: LiveData<Map<String, Int>> = state.geohashParticipantCounts
val selectedLocationChannel: LiveData<com.bitchat.android.geohash.ChannelID?> = state.selectedLocationChannel
fun initialize() {
subscriptionManager.connect()
val identity = NostrIdentityBridge.getCurrentNostrIdentity(getApplication())
if (identity != null) {
// Use global chat-messages only for full account DMs (mesh context). For geohash DMs, subscribe per-geohash below.
subscriptionManager.subscribeGiftWraps(
pubkey = identity.publicKeyHex,
sinceMs = System.currentTimeMillis() - 172800000L,
id = "chat-messages",
handler = { event -> dmHandler.onGiftWrap(event, "", identity) } // geohash="" means global account DM (not geohash identity)
)
}
try {
locationChannelManager = com.bitchat.android.geohash.LocationChannelManager.getInstance(getApplication())
locationChannelManager?.selectedChannel?.observeForever { channel ->
state.setSelectedLocationChannel(channel)
switchLocationChannel(channel)
}
locationChannelManager?.teleported?.observeForever { teleported ->
state.setIsTeleported(teleported)
}
} catch (e: Exception) {
Log.e(TAG, "Failed to initialize location channel state: ${e.message}")
state.setSelectedLocationChannel(com.bitchat.android.geohash.ChannelID.Mesh)
state.setIsTeleported(false)
}
}
fun panicReset() {
repo.clearAll()
subscriptionManager.disconnect()
currentGeohashSubId = null
currentDmSubId = null
geoTimer?.cancel()
geoTimer = null
try { NostrIdentityBridge.clearAllAssociations(getApplication()) } catch (_: Exception) {}
initialize()
}
fun sendGeohashMessage(content: String, channel: com.bitchat.android.geohash.GeohashChannel, myPeerID: String, nickname: String?) {
viewModelScope.launch {
try {
val tempId = "temp_${System.currentTimeMillis()}_${kotlin.random.Random.nextInt(1000)}"
val pow = PoWPreferenceManager.getCurrentSettings()
val localMsg = com.bitchat.android.model.BitchatMessage(
id = tempId,
sender = nickname ?: myPeerID,
content = content,
timestamp = Date(),
isRelay = false,
senderPeerID = "geohash:${channel.geohash}",
channel = "#${channel.geohash}",
powDifficulty = if (pow.enabled) pow.difficulty else null
)
messageManager.addChannelMessage("geo:${channel.geohash}", localMsg)
if (pow.enabled && pow.difficulty > 0) {
com.bitchat.android.ui.PoWMiningTracker.startMiningMessage(tempId)
}
val identity = NostrIdentityBridge.deriveIdentity(forGeohash = channel.geohash, context = getApplication())
val teleported = state.isTeleported.value ?: false
val event = NostrProtocol.createEphemeralGeohashEvent(content, channel.geohash, identity, nickname, teleported)
val relayManager = NostrRelayManager.getInstance(getApplication())
relayManager.sendEventToGeohash(event, channel.geohash, includeDefaults = false, nRelays = 5)
} catch (e: Exception) {
Log.e(TAG, "Failed to send geohash message: ${e.message}")
}
}
}
fun beginGeohashSampling(geohashes: List<String>) {
if (geohashes.isEmpty()) return
Log.d(TAG, "🌍 Beginning geohash sampling for ${geohashes.size} geohashes")
viewModelScope.launch {
geohashes.forEach { geohash ->
subscriptionManager.subscribeGeohash(
geohash = geohash,
sinceMs = System.currentTimeMillis() - 86400000L,
limit = 200,
id = "sampling-$geohash",
handler = { event -> geohashMessageHandler.onEvent(event, geohash) }
)
}
}
}
fun endGeohashSampling() { Log.d(TAG, "🌍 Ending geohash sampling") }
fun geohashParticipantCount(geohash: String): Int = repo.geohashParticipantCount(geohash)
fun isPersonTeleported(pubkeyHex: String): Boolean = repo.isPersonTeleported(pubkeyHex)
fun startGeohashDM(pubkeyHex: String, onStartPrivateChat: (String) -> Unit) {
val convKey = "nostr_${pubkeyHex.take(16)}"
repo.putNostrKeyMapping(convKey, pubkeyHex)
onStartPrivateChat(convKey)
Log.d(TAG, "🗨️ Started geohash DM with $pubkeyHex -> $convKey")
}
fun getNostrKeyMapping(): Map<String, String> = repo.getNostrKeyMapping()
fun blockUserInGeohash(targetNickname: String) {
val pubkey = repo.findPubkeyByNickname(targetNickname)
if (pubkey != null) {
dataManager.addGeohashBlockedUser(pubkey)
val sysMsg = com.bitchat.android.model.BitchatMessage(
sender = "system",
content = "blocked $targetNickname in geohash channels",
timestamp = Date(),
isRelay = false
)
fun startGeohashDM(pubkeyHex: String, onStartPrivateChat: (String) -> Unit) {
val convKey = "nostr_${'$'}{pubkeyHex.take(16)}"
repo.putNostrKeyMapping(convKey, pubkeyHex)
// Record the conversation's geohash using the currently selected location channel (if any)
val current = state.selectedLocationChannel.value
val gh = (current as? com.bitchat.android.geohash.ChannelID.Location)?.channel?.geohash
if (!gh.isNullOrEmpty()) {
repo.setConversationGeohash(convKey, gh)
com.bitchat.android.nostr.GeohashConversationRegistry.set(convKey, gh)
}
onStartPrivateChat(convKey)
Log.d(TAG, "🗨️ Started geohash DM with ${'$'}pubkeyHex -> ${'$'}convKey (geohash=${'$'}gh)")
}
messageManager.addMessage(sysMsg)
} else {
val sysMsg = com.bitchat.android.model.BitchatMessage(
sender = "system",
content = "user '$targetNickname' not found in current geohash",
timestamp = Date(),
isRelay = false
)
messageManager.addMessage(sysMsg)
}
}
fun selectLocationChannel(channel: com.bitchat.android.geohash.ChannelID) {
locationChannelManager?.select(channel) ?: run { Log.w(TAG, "Cannot select location channel - not initialized") }
}
fun displayNameForNostrPubkeyUI(pubkeyHex: String): String = repo.displayNameForNostrPubkeyUI(pubkeyHex)
fun colorForNostrPubkey(pubkeyHex: String, isDark: Boolean): androidx.compose.ui.graphics.Color {
val seed = "nostr:${pubkeyHex.lowercase()}"
return colorForPeerSeed(seed, isDark).copy()
}
private fun switchLocationChannel(channel: com.bitchat.android.geohash.ChannelID?) {
geoTimer?.cancel(); geoTimer = null
currentGeohashSubId?.let { subscriptionManager.unsubscribe(it); currentGeohashSubId = null }
currentDmSubId?.let { subscriptionManager.unsubscribe(it); currentDmSubId = null }
when (channel) {
is com.bitchat.android.geohash.ChannelID.Mesh -> {
Log.d(TAG, "📡 Switched to mesh channel")
repo.setCurrentGeohash(null)
notificationManager.setCurrentGeohash(null)
notificationManager.clearMeshMentionNotifications()
repo.refreshGeohashPeople()
}
is com.bitchat.android.geohash.ChannelID.Location -> {
Log.d(TAG, "📍 Switching to geohash channel: ${channel.channel.geohash}")
repo.setCurrentGeohash(channel.channel.geohash)
notificationManager.setCurrentGeohash(channel.channel.geohash)
notificationManager.clearNotificationsForGeohash(channel.channel.geohash)
try { messageManager.clearChannelUnreadCount("geo:${channel.channel.geohash}") } catch (_: Exception) { }
try {
val identity = NostrIdentityBridge.deriveIdentity(channel.channel.geohash, getApplication())
repo.updateParticipant(channel.channel.geohash, identity.publicKeyHex, Date())
val teleported = state.isTeleported.value ?: false
if (teleported) repo.markTeleported(identity.publicKeyHex)
} catch (e: Exception) { Log.w(TAG, "Failed identity setup: ${e.message}") }
startGeoParticipantsTimer()
viewModelScope.launch {
val geohash = channel.channel.geohash
val subId = "geohash-$geohash"; currentGeohashSubId = subId
subscriptionManager.subscribeGeohash(
geohash = geohash,
sinceMs = System.currentTimeMillis() - 3600000L,
limit = 200,
id = subId,
handler = { event -> geohashMessageHandler.onEvent(event, geohash) }
)
val dmIdentity = NostrIdentityBridge.deriveIdentity(geohash, getApplication())
val dmSubId = "geo-dm-$geohash"; currentDmSubId = dmSubId
subscriptionManager.subscribeGiftWraps(
pubkey = dmIdentity.publicKeyHex,
sinceMs = System.currentTimeMillis() - 172800000L,
id = dmSubId,
handler = { event -> dmHandler.onGiftWrap(event, geohash, dmIdentity) }
)
// Also register alias in global registry for routing convenience
com.bitchat.android.nostr.GeohashAliasRegistry.put("nostr_${dmIdentity.publicKeyHex.take(16)}", dmIdentity.publicKeyHex)
}
}
null -> {
Log.d(TAG, "📡 No channel selected")
repo.setCurrentGeohash(null)
repo.refreshGeohashPeople()
}
}
}
private fun startGeoParticipantsTimer() {
geoTimer = viewModelScope.launch {
while (repo.getCurrentGeohash() != null) {
delay(30000)
repo.refreshGeohashPeople()
}
}
}
}
@@ -10,6 +10,7 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Map
import androidx.compose.material.icons.filled.PinDrop
import androidx.compose.material3.*
import androidx.compose.ui.text.font.FontWeight
@@ -30,6 +31,8 @@ import com.bitchat.android.geohash.GeohashChannel
import com.bitchat.android.geohash.GeohashChannelLevel
import com.bitchat.android.geohash.LocationChannelManager
import java.util.*
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
/**
* Location Channels Sheet for selecting geohash-based location channels
@@ -71,6 +74,18 @@ fun LocationChannelsSheet(
// Scroll state for LazyColumn
val listState = rememberLazyListState()
val mapPickerLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult()
) { result ->
if (result.resultCode == android.app.Activity.RESULT_OK) {
val gh = result.data?.getStringExtra(GeohashPickerActivity.EXTRA_RESULT_GEOHASH)
if (!gh.isNullOrBlank()) {
customGeohash = gh
customError = null
}
}
}
// iOS system colors (matches iOS exactly)
val colorScheme = MaterialTheme.colorScheme
val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
@@ -312,6 +327,26 @@ fun LocationChannelsSheet(
)
val normalized = customGeohash.trim().lowercase().replace("#", "")
// Map picker button
IconButton(onClick = {
val initial = when {
normalized.isNotBlank() -> normalized
selectedChannel is ChannelID.Location -> (selectedChannel as ChannelID.Location).channel.geohash
else -> ""
}
val intent = Intent(context, GeohashPickerActivity::class.java).apply {
putExtra(GeohashPickerActivity.EXTRA_INITIAL_GEOHASH, initial)
}
mapPickerLauncher.launch(intent)
}) {
Icon(
imageVector = Icons.Filled.Map,
contentDescription = "Open map",
tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f)
)
}
val isValid = validateGeohash(normalized)
// iOS-style teleport button
@@ -107,16 +107,21 @@ class MeshDelegateHandler(
meshNoiseKeyForPeer = { pid -> getPeerInfo(pid)?.noisePublicKey },
meshHasPeer = { pid -> peers.contains(pid) },
nostrPubHexForAlias = { alias ->
// Best-effort: derive pub hex from favorites mapping
val prefix = alias.removePrefix("nostr_")
val favs = try { com.bitchat.android.favorites.FavoritesPersistenceService.shared.getOurFavorites() } catch (_: Exception) { emptyList() }
favs.firstNotNullOfOrNull { rel ->
rel.peerNostrPublicKey?.let { s ->
runCatching { com.bitchat.android.nostr.Bech32.decode(s) }.getOrNull()?.let { dec ->
if (dec.first == "npub") dec.second.joinToString("") { b -> "%02x".format(b) } else null
// Use GeohashAliasRegistry for geohash aliases, but for mesh favorites, derive from favorites mapping
if (com.bitchat.android.nostr.GeohashAliasRegistry.contains(alias)) {
com.bitchat.android.nostr.GeohashAliasRegistry.get(alias)
} else {
// Best-effort: derive pub hex from favorites mapping for mesh nostr_ aliases
val prefix = alias.removePrefix("nostr_")
val favs = try { com.bitchat.android.favorites.FavoritesPersistenceService.shared.getOurFavorites() } catch (_: Exception) { emptyList() }
favs.firstNotNullOfOrNull { rel ->
rel.peerNostrPublicKey?.let { s ->
runCatching { com.bitchat.android.nostr.Bech32.decode(s) }.getOrNull()?.let { dec ->
if (dec.first == "npub") dec.second.joinToString("") { b -> "%02x".format(b) } else null
}
}
}
}?.takeIf { it.startsWith(prefix, ignoreCase = true) }
}?.takeIf { it.startsWith(prefix, ignoreCase = true) }
}
},
findNoiseKeyForNostr = { key -> com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNoiseKey(key) }
)
@@ -52,8 +52,18 @@ class MessageManager(private val state: ChatState) {
currentChannelMessages[channel] = channelMessageList
state.setChannelMessages(currentChannelMessages)
// Update unread count if not currently in this channel
if (state.getCurrentChannelValue() != channel) {
// Update unread count if not currently viewing this channel
// Consider both classic channels (state.currentChannel) and geohash location channel selection
val viewingClassicChannel = state.getCurrentChannelValue() == channel
val viewingGeohashChannel = try {
if (channel.startsWith("geo:")) {
val geo = channel.removePrefix("geo:")
val selected = state.selectedLocationChannel.value
selected is com.bitchat.android.geohash.ChannelID.Location && selected.channel.geohash.equals(geo, ignoreCase = true)
} else false
} catch (_: Exception) { false }
if (!viewingClassicChannel && !viewingGeohashChannel) {
val currentUnread = state.getUnreadChannelMessagesValue().toMutableMap()
currentUnread[channel] = (currentUnread[channel] ?: 0) + 1
state.setUnreadChannelMessages(currentUnread)
@@ -3,6 +3,8 @@ package com.bitchat.android.ui
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.DeliveryStatus
import com.bitchat.android.mesh.PeerFingerprintManager
import java.security.MessageDigest
import com.bitchat.android.mesh.BluetoothMeshService
import java.util.*
import android.util.Log
@@ -123,21 +125,40 @@ class PrivateChatManager(
}
fun toggleFavorite(peerID: String) {
val fingerprint = fingerprintManager.getFingerprintForPeer(peerID) ?: return
var fingerprint = fingerprintManager.getFingerprintForPeer(peerID)
// Fallback: if this looks like a 64-hex Noise public key (offline favorite entry),
// compute a synthetic fingerprint (SHA-256 of public key) to allow unfollowing offline peers
if (fingerprint == null && peerID.length == 64 && peerID.matches(Regex("^[0-9a-fA-F]+$"))) {
try {
val pubBytes = peerID.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
val digest = java.security.MessageDigest.getInstance("SHA-256")
val fpBytes = digest.digest(pubBytes)
fingerprint = fpBytes.joinToString("") { "%02x".format(it) }
Log.d(TAG, "Computed fingerprint from noise key hex for offline toggle: $fingerprint")
} catch (e: Exception) {
Log.w(TAG, "Failed to compute fingerprint from noise key hex: ${e.message}")
}
}
if (fingerprint == null) {
Log.w(TAG, "toggleFavorite: no fingerprint for peerID=$peerID; ignoring toggle")
return
}
Log.d(TAG, "toggleFavorite called for peerID: $peerID, fingerprint: $fingerprint")
val wasFavorite = dataManager.isFavorite(fingerprint)
val wasFavorite = dataManager.isFavorite(fingerprint!!)
Log.d(TAG, "Current favorite status: $wasFavorite")
val currentFavorites = state.getFavoritePeersValue()
Log.d(TAG, "Current UI state favorites: $currentFavorites")
if (wasFavorite) {
dataManager.removeFavorite(fingerprint)
dataManager.removeFavorite(fingerprint!!)
Log.d(TAG, "Removed from favorites: $fingerprint")
} else {
dataManager.addFavorite(fingerprint)
dataManager.addFavorite(fingerprint!!)
Log.d(TAG, "Added to favorites: $fingerprint")
}
@@ -149,6 +170,7 @@ class PrivateChatManager(
Log.d(TAG, "All peer fingerprints: ${fingerprintManager.getAllPeerFingerprints()}")
}
fun isFavorite(peerID: String): Boolean {
val fingerprint = fingerprintManager.getFingerprintForPeer(peerID) ?: return false
val isFav = dataManager.isFavorite(fingerprint)
@@ -365,87 +387,12 @@ class PrivateChatManager(
"Our peer ID lexicographically >= target peer ID, sending identity announcement to prompt handshake from $peerID"
)
meshService.sendAnnouncementToPeer(peerID)
Log.d(TAG, "Sent identity announcement to $peerID starting handshake now from our side")
noiseSessionDelegate.initiateHandshake(peerID)
}
}
// /**
// * Legacy reflection-based implementation for backward compatibility
// */
// private fun establishNoiseSessionIfNeededLegacy(peerID: String, meshService: Any) {
// try {
// // Check if we already have an established Noise session with this peer
// val hasSessionMethod = meshService::class.java.getDeclaredMethod("hasEstablishedSession", String::class.java)
// val hasSession = hasSessionMethod.invoke(meshService, peerID) as Boolean
//
// if (hasSession) {
// Log.d(TAG, "Noise session already established with $peerID")
// return
// }
//
// Log.d(TAG, "No Noise session with $peerID, determining who should initiate handshake")
//
// // Get our peer ID from mesh service for lexicographical comparison
// val myPeerIDField = meshService::class.java.getField("myPeerID")
// val myPeerID = myPeerIDField.get(meshService) as String
//
// // Use lexicographical comparison to decide who initiates (same logic as MessageHandler)
// if (myPeerID < peerID) {
// // We should initiate the handshake
// Log.d(TAG, "Our peer ID lexicographically < target peer ID, initiating Noise handshake with $peerID")
// initiateHandshakeWithPeer(peerID, meshService)
// } else {
// // They should initiate, we send a Noise identity announcement
// Log.d(TAG, "Our peer ID lexicographically >= target peer ID, sending Noise identity announcement to prompt handshake from $peerID")
// sendNoiseIdentityAnnouncement(meshService)
// }
//
// } catch (e: Exception) {
// Log.e(TAG, "Failed to establish Noise session with $peerID: ${e.message}")
// }
// }
/**
* Initiate handshake with specific peer using the existing delegate pattern
*/
private fun initiateHandshakeWithPeer(peerID: String, meshService: Any) {
try {
// Use the existing MessageHandler delegate approach to initiate handshake
// This calls the same code that's in MessageHandler's delegate.initiateNoiseHandshake()
val messageHandler = meshService::class.java.getDeclaredField("messageHandler")
messageHandler.isAccessible = true
val handler = messageHandler.get(meshService)
val delegate = handler::class.java.getDeclaredField("delegate")
delegate.isAccessible = true
val handlerDelegate = delegate.get(handler)
val method =
handlerDelegate::class.java.getMethod("initiateNoiseHandshake", String::class.java)
method.invoke(handlerDelegate, peerID)
Log.d(TAG, "Successfully initiated Noise handshake with $peerID using delegate pattern")
} catch (e: Exception) {
Log.e(TAG, "Failed to initiate Noise handshake with $peerID: ${e.message}")
}
}
/**
* Send Noise identity announcement to prompt other peer to initiate handshake
* This follows the same pattern as broadcastNoiseIdentityAnnouncement() in BluetoothMeshService
*/
private fun sendNoiseIdentityAnnouncement(meshService: Any) {
try {
// Call broadcastNoiseIdentityAnnouncement which sends a NoiseIdentityAnnouncement
val method =
meshService::class.java.getDeclaredMethod("broadcastNoiseIdentityAnnouncement")
method.invoke(meshService)
Log.d(TAG, "Successfully sent Noise identity announcement")
} catch (e: Exception) {
Log.e(TAG, "Failed to send Noise identity announcement: ${e.message}")
}
}
// MARK: - Utility Functions
private fun getPeerIDForNickname(nickname: String, meshService: BluetoothMeshService): String? {
@@ -413,6 +413,22 @@ fun PeopleSection(
val isMappedToConnected = noiseHexByPeerID.values.any { it.equals(favPeerID, ignoreCase = true) }
if (isMappedToConnected) return@forEach
// Resolve potential Nostr conversation key for this favorite (for unread detection)
val nostrConvKey: String? = try {
val npubOrHex = com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNostrPubkey(fav.peerNoisePublicKey)
if (npubOrHex != null) {
val hex = if (npubOrHex.startsWith("npub")) {
val (hrp, data) = com.bitchat.android.nostr.Bech32.decode(npubOrHex)
if (hrp == "npub") data.joinToString("") { "%02x".format(it) } else null
} else {
npubOrHex.lowercase()
}
hex?.let { "nostr_${it.take(16)}" }
} else null
} catch (_: Exception) { null }
val hasUnread = hasUnreadPrivateMessages.contains(favPeerID) || (nostrConvKey != null && hasUnreadPrivateMessages.contains(nostrConvKey))
// If user clicks an offline favorite and the mapped peer is currently connected under a different ID,
// open chat with the connected peerID instead of the noise hex for a seamless window
val mappedConnectedPeerID = noiseHexByPeerID.entries.firstOrNull { it.value.equals(favPeerID, ignoreCase = true) }?.key
@@ -420,13 +436,20 @@ fun PeopleSection(
val (bName, _) = com.bitchat.android.ui.splitSuffix(dn)
val showHash = (baseNameCounts[bName] ?: 0) > 1
// Compute unreadCount from either noise conversation or Nostr conversation
val unreadCount = (
privateChats[favPeerID]?.count { msg -> msg.sender != nickname && hasUnreadPrivateMessages.contains(favPeerID) } ?: 0
) + (
if (nostrConvKey != null) privateChats[nostrConvKey]?.count { msg -> msg.sender != nickname && hasUnreadPrivateMessages.contains(nostrConvKey) } ?: 0 else 0
)
PeerItem(
peerID = favPeerID,
displayName = dn,
isDirect = false,
isSelected = (mappedConnectedPeerID ?: favPeerID) == selectedPrivatePeer,
isFavorite = true,
hasUnreadDM = hasUnreadPrivateMessages.contains(favPeerID),
hasUnreadDM = hasUnread,
colorScheme = colorScheme,
viewModel = viewModel,
onItemClick = { onPrivateChatStart(mappedConnectedPeerID ?: favPeerID) },
@@ -434,21 +457,23 @@ fun PeopleSection(
Log.d("SidebarComponents", "Sidebar toggle favorite (offline): peerID=$favPeerID")
viewModel.toggleFavorite(favPeerID)
},
unreadCount = privateChats[favPeerID]?.count { msg ->
msg.sender != nickname && hasUnreadPrivateMessages.contains(favPeerID)
} ?: if (hasUnreadPrivateMessages.contains(favPeerID)) 1 else 0,
unreadCount = if (unreadCount > 0) unreadCount else if (hasUnread) 1 else 0,
showNostrGlobe = (fav.isMutual && fav.peerNostrPublicKey != null),
showHashSuffix = showHash
)
appendedOfflineIds.add(favPeerID)
}
// Also show any incoming Nostr chats that exist locally but are not in connected peers or favorites yet
// This ensures a user can open and read Nostr messages while the sender remains offline
// NOTE: Do NOT append Nostr-only (nostr_*) conversations to the mesh people list.
// Geohash DMs should appear in the GeohashPeople list for the active geohash, not in mesh offline contacts.
// We intentionally remove previously-added behavior that mixed geohash DMs into mesh sidebar.
// If you need to surface non-geohash offline mesh conversations in the future, do it here for 64-hex noise IDs only.
/*
val alreadyShownIds = connectedIds + appendedOfflineIds
privateChats.keys
.filter { key ->
(key.startsWith("nostr_") || hex64Regex.matches(key)) &&
// Only include 64-hex noise IDs (mesh identities); exclude any nostr_* aliases
hex64Regex.matches(key) &&
!alreadyShownIds.contains(key) &&
// Skip if this key maps to a connected peer via noiseHex mapping
!noiseHexByPeerID.values.any { it.equals(key, ignoreCase = true) }
@@ -460,24 +485,27 @@ fun PeopleSection(
val (bName, _) = com.bitchat.android.ui.splitSuffix(dn)
val showHash = (baseNameCounts[bName] ?: 0) > 1
PeerItem(
peerID = convKey,
displayName = dn,
isDirect = false,
isSelected = convKey == selectedPrivatePeer,
isFavorite = false,
hasUnreadDM = hasUnreadPrivateMessages.contains(convKey),
colorScheme = colorScheme,
viewModel = viewModel,
PeerItem(
peerID = convKey,
displayName = dn,
isDirect = false,
isSelected = convKey == selectedPrivatePeer,
isFavorite = false,
hasUnreadDM = hasUnreadPrivateMessages.contains(convKey),
colorScheme = colorScheme,
viewModel = viewModel,
onItemClick = { onPrivateChatStart(convKey) },
onToggleFavorite = { viewModel.toggleFavorite(convKey) },
unreadCount = privateChats[convKey]?.count { msg ->
msg.sender != nickname && hasUnreadPrivateMessages.contains(convKey)
} ?: if (hasUnreadPrivateMessages.contains(convKey)) 1 else 0,
showNostrGlobe = true,
showNostrGlobe = false,
showHashSuffix = showHash
)
}
*/
// End intentional removal
}
}
@@ -538,6 +566,15 @@ private fun PeerItem(
modifier = Modifier.size(16.dp),
tint = Color(0xFF9C27B0) // Purple
)
} else if (!isDirect && isFavorite) {
// Offline favorited user: show outlined circle icon
Icon(
//painter = androidx.compose.ui.res.painterResource(id = R.drawable.ic_offline_favorite),
imageVector = Icons.Outlined.Circle,
contentDescription = "Offline favorite",
modifier = Modifier.size(16.dp),
tint = Color.Gray
)
} else {
Icon(
imageVector = if (isDirect) Icons.Outlined.SettingsInputAntenna else Icons.Filled.Route,
@@ -262,6 +262,8 @@ fun DebugSettingsSheet(
steps = 30
)
}
val overallCount = connectedDevices.size
Text("connections: $overallCount / $maxOverall", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
Row(verticalAlignment = Alignment.CenterVertically) {
Text("max overall", fontFamily = FontFamily.Monospace, modifier = Modifier.width(90.dp))
Slider(
@@ -307,29 +309,35 @@ fun DebugSettingsSheet(
}
val maxValRaw = series.maxOrNull() ?: 0f
val maxVal = if (maxValRaw > 0f) maxValRaw else 0f
Box(Modifier.fillMaxWidth().height(48.dp)) {
val leftGutter = 40.dp
Box(Modifier.fillMaxWidth().height(56.dp)) {
// Graph canvas
androidx.compose.foundation.Canvas(Modifier.fillMaxSize()) {
val axisPx = 28.dp.toPx() // leave room on left for ticks
val axisPx = leftGutter.toPx() // reserved left gutter for labels
val barCount = series.size
val availW = (size.width - axisPx).coerceAtLeast(1f)
val w = availW / barCount
val h = size.height
// draw baseline at y=0 (bottom)
// Baseline at bottom (y = 0)
drawLine(
color = Color(0x33888888),
start = androidx.compose.ui.geometry.Offset(axisPx, h - 1f),
end = androidx.compose.ui.geometry.Offset(size.width, h - 1f),
strokeWidth = 1f
)
// Bars from bottom-up; skip zeros entirely
series.forEachIndexed { i, value ->
val ratio = if (maxVal > 0f) (value / maxVal).coerceIn(0f, 1f) else 0f // min always 0
val barHeight = h * ratio
// Draw bars from bottom up, starting after left axis area
drawRect(
color = Color(0xFF00C851),
topLeft = androidx.compose.ui.geometry.Offset(x = axisPx + i * w, y = h - barHeight),
size = androidx.compose.ui.geometry.Size(w, barHeight)
)
if (value > 0f && maxVal > 0f) {
val ratio = (value / maxVal).coerceIn(0f, 1f)
val barHeight = (h * ratio).coerceAtLeast(0f)
if (barHeight > 0.5f) {
drawRect(
color = Color(0xFF00C851),
topLeft = androidx.compose.ui.geometry.Offset(x = axisPx + i * w, y = h - barHeight),
size = androidx.compose.ui.geometry.Size(w, barHeight)
)
}
}
}
}
// Y-axis ticks (min/max) in the left margin