Compare commits

..

No commits in common. "main" and "v2.0.2" have entirely different histories.
main ... v2.0.2

30 changed files with 936 additions and 1486 deletions

View File

@ -1,391 +1,389 @@
Relay URL,Latitude,Longitude
myvoiceourstory.org,37.3598,-121.981
nostr.whitenode45.ddns.net,40.55,-74.4758
relay5.bitransfer.org,43.6532,-79.3832
relay.littlebitstudios.com,43.6532,-79.3832
public.crostr.com,43.6532,-79.3832
nostr-verified.wellorder.net,45.5201,-122.99
nostr-relay.xbytez.io,50.6924,3.20113
nostr.azzamo.net:443,52.2633,21.0283
yabu.me,35.6092,139.73
relay.arx-ccn.com,50.4754,12.3683
relay.ohstr.com:443,43.6532,-79.3832
relay.nearhood.co.uk,51.5134,-0.0890675
purplerelay.com,43.6532,-79.3832
cs-relay.nostrdev.com,50.4754,12.3683
nostr.red5d.dev,43.6532,-79.3832
relay-fra.zombi.cloudrodion.com,48.8566,2.35222
relay.lightning.pub:443,39.0438,-77.4874
relay.mccormick.cx:443,52.3563,4.95714
fanfares.nostr1.com:443,40.7057,-74.0136
relay.shadowbip.com,54.352,18.6466
nip85.nosfabrica.com,39.0997,-94.5786
social.amanah.eblessing.co,48.1046,11.6002
relay.openspecs.uid.ovh,50.9871,2.12554
relay.pyramid.li,47.4093,8.46503
bcast.girino.org,43.6532,-79.3832
relay.jmoose.rocks:443,32.8009,-96.8195
relay.solife.me,43.6532,-79.3832
relay.flashapp.me,43.6548,-79.3885
relay.wavlake.com:443,41.2619,-95.8608
nostr.plantroon.com:443,50.1013,8.62643
relay.lanacoin-eternity.com,40.8302,-74.1299
nostr.infero.net,35.6764,139.65
dm-test-strfry-generic.samt.st,43.6532,-79.3832
nostrelay.circum.space:443,52.2245,8.826
dm-test-nostr-rs-42-disabled.samt.st,43.6532,-79.3832
relay2.fiatdenier.com,50.1013,8.62643
nostr.l484.com,30.2672,-97.7431
relay.nostr.net,43.6532,-79.3832
relay.nuts.cash,52.3676,4.90414
nostr.bond,50.1109,8.68213
nostr.debate.report,50.1109,8.68213
prl.plus,55.7558,37.6173
temp.iris.to,43.6532,-79.3832
relay.noeudlibre.fr,50.6924,3.20113
relay.routstr.com,59.4016,17.9455
relay.islandbitcoin.com,20.3898,78.0903
nostr.linky.fit,50.1109,8.68213
bitchat.nostr1.com,40.7057,-74.0136
relay.lanavault.space,60.1699,24.9384
relayone.geektank.ai:443,39.0997,-94.5786
nostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874
relay.kizuna-miki.com,35.694,139.754
relay.edufeed.org:443,49.4521,11.0767
nr.yay.so,46.2126,6.1154
nostr.oxtr.dev:443,50.4754,12.3683
relay.primal.net,43.6532,-79.3832
relay.staging.plebeian.market,51.5072,-0.127586
nostr.planix.org,43.6532,-79.3832
relay.artio.inf.unibe.ch,46.9501,7.43678
chorus.mikedilger.com:444,-36.8906,174.794
nrs-01.darkcloudarcade.com:443,39.0997,-94.5786
relay.pocketstr.com,40.8302,-74.1299
nostr.middling.mydns.jp,35.8099,140.12
offchain.pub:443,39.1585,-94.5728
cache.trustr.ing,43.6548,-79.3885
nostrride.io,37.3986,-121.964
relay.nostrfy.org,35.6916,139.768
wheat.happytavern.co,43.6532,-79.3832
relayone.soundhsa.com:443,39.0997,-94.5786
relay.satsmarkt.club,52.6907,4.8181
nexus.libernet.app,43.6532,-79.3832
relay.directsponsor.net,42.8864,-78.8784
nostr.spicyz.io,43.6532,-79.3832
nostr.rtvslawenia.com,49.4543,11.0746
relay.trotters.cc,43.6532,-79.3832
relayone.soundhsa.com,39.0997,-94.5786
nostr.na.social,43.6532,-79.3832
ribo.us.nostria.app:443,43.6532,-79.3832
nostr.hekster.org,37.3986,-121.964
relay.nostr.dev.br,48.1046,11.6002
relay.keykeeper.world,40.7824,-74.0711
nostr.pbfs.io,50.4754,12.3683
nostr.girino.org,43.6532,-79.3832
relay1.privkey.io,25.7617,-80.1918
relay.sharegap.net,43.6532,-79.3832
nostr.chaima.info:443,50.1109,8.68213
relay.vrtmrz.net:443,43.6532,-79.3832
relay.gulugulu.moe:443,43.6532,-79.3832
relay.dreamith.to,43.6532,-79.3832
nostr2.girino.org,43.6532,-79.3832
nostr.bitcoiner.social,47.6743,-117.112
relay.nostu.be,40.4167,-3.70329
relay.mwaters.net,50.9871,2.12554
nostr.islandarea.net,35.4669,-97.6473
chorus.bonsai.com,39.0438,-77.4874
nostr.yutakobayashi.com,43.6532,-79.3832
nostras.app,38.7946,-106.535
relay.yoinekodo.jp,43.6532,-79.3832
top.testrelay.top,43.6532,-79.3832
nostr-01.uid.ovh,50.9871,2.12554
relay.orly.dev,32.7767,-96.797
offchain.bostr.online,43.6532,-79.3832
nostr-rs-relay.dev.fedibtc.com:443,39.0438,-77.4874
conduitl2.fly.dev,38.7946,-106.535
nostrride.io,37.3986,-121.964
nostr.n7ekb.net,47.4941,-122.294
relay.44billion.net,43.6532,-79.3832
nostrcity-club.fly.dev,38.7946,-106.535
syb.lol,34.0549,-118.243
relay.mypathtofire.de,42.8864,-78.8784
nostr.relay.hedwig.sh,60.1699,24.9384
nostr.myshosholoza.co.za:443,52.3676,4.90414
relay.npubhaus.com,43.6532,-79.3832
relay1.privkey.io,25.7617,-80.1918
nostr-relay.xbytez.io:443,50.6924,3.20113
relay.bullishbounty.com,43.6532,-79.3832
nostr.islandarea.net,35.4669,-97.6473
nostrrelay.taylorperron.com,45.5029,-73.5723
relay.sharegap.net,43.6532,-79.3832
relayone.geektank.ai,39.0997,-94.5786
nexus.libernet.app,43.6532,-79.3832
fanfares.nostr1.com:443,40.7057,-74.0136
relay.satlantis.io,39.0438,-77.4874
relay.hivetalk.org,40.8302,-74.1299
nos.lol,50.4754,12.3683
staging.yabu.me,35.6092,139.73
relay-us.zombi.cloudrodion.com,40.7862,-74.0743
relay.internationalright-wing.org,-22.4692,-48.9875
relay.nostrcheck.me,43.6532,-79.3832
denimroad.feeds.relay.tools,38.6327,-90.1961
relay.nostr-check.me,43.6532,-79.3832
ribo.us.nostria.app:443,43.6532,-79.3832
relay.lightning.pub,39.0438,-77.4874
myvoiceourstory.org,37.3598,-121.981
relay.jmoose.rocks:443,37.4419,-122.143
nostr-pr02.redscrypt.org,52.3676,4.90414
nostr.purpura.cloud,43.6532,-79.3832
relay.sigit.io:443,50.4754,12.3683
relay.nostrops.com,32.71,-96.6745
relay.jmoose.rocks,37.4419,-122.143
nostr.spaceshell.xyz,43.6532,-79.3832
nostr-pub.wellorder.net,45.5201,-122.99
relay.opmaat.org,60.1699,24.9384
nostr.myshosholoza.co.za,52.3676,4.90414
offchain.bostr.online,43.6532,-79.3832
nostr-relay.nilpote.com,43.6532,-79.3832
relay.nostu.be,40.4167,-3.70329
nostr.stakey.net,52.3676,4.90414
dm-test-strfry-discovery.samt.st,43.6532,-79.3832
conduitl2.fly.dev,38.7946,-106.535
vault.iris.to:443,43.6532,-79.3832
relay.minibolt.info,43.6532,-79.3832
nostr.fullstackcash.net,45.5201,-122.99
bendernostur.duckdns.org:8443,50.1109,8.68213
articles.layer3.news:443,37.3387,-121.885
nostr.overmind.lol,43.6532,-79.3832
nostr.21crypto.ch,47.5356,8.73209
nostr.tabordalab.com,60.1699,24.9384
nostr.spicyz.io,43.6532,-79.3832
nostr.sathoarder.com,48.5734,7.75211
relay.chorus.community,48.5333,10.7
dev.relay.stream,43.6532,-79.3832
nostr-relay.corb.net:443,39.6478,-104.988
relay.lightning.pub:443,39.0438,-77.4874
nostr.tbxnetworx.de,47.8226,10.0064
nostr.azzamo.net,52.2633,21.0283
relay.loveisbitcoin.com,43.6532,-79.3832
strfry.ymir.cloud,43.6532,-79.3832
chorus.mikedilger.com:444,-36.8906,174.794
relay.agilesolutionlabs.com,43.6532,-79.3832
relay.gulugulu.moe,43.6532,-79.3832
nostr.tac.lol,47.4748,-122.273
relay.mypathtofire.de,42.8864,-78.8784
nostrelay.circum.space,52.2245,8.826
bcast.girino.org,43.6532,-79.3832
relay.ordoplay.com,50.1109,8.68213
nostr.chaima.info,50.1109,8.68213
offchain.pub:443,39.1585,-94.5728
relay.wisp.talk,49.4543,11.0746
strfry.bonsai.com,39.0438,-77.4874
nostr.2b9t.xyz:443,34.0549,-118.243
relay.nostx.io,43.6532,-79.3832
bucket.coracle.social,37.7775,-122.397
relay.wavlake.com:443,41.2619,-95.8608
nostr.carroarmato0.be:443,50.914,3.21378
treuzkas.branruz.com,48.8575,2.35138
nostr.computingcache.com,45.5341,-122.956
cs-relay.nostrdev.com,50.4754,12.3683
relay.lanacoin-eternity.com,40.8302,-74.1299
nostr.rtvslawenia.com,49.4543,11.0746
nip85.nosfabrica.com,39.0997,-94.5786
relay.wisp.talk:443,49.4543,11.0746
nostr.stakey.net:443,52.3676,4.90414
relay.piazza.today,48.122,11.589
relay.conduit.market,38.7946,-106.535
relay.nostrmap.net,60.1699,24.9384
maxq.descendant.io,43.6532,-79.3832
nostr.hekster.org,37.3986,-121.964
nostr.christiansass.de,49.7423,8.76687
tribune-panel-growing-noon.trycloudflare.com,43.6532,-79.3832
relay.lanacoin-eternity.com:443,40.8302,-74.1299
relay.scuba323.com,40.8218,-74.45
schnorr.me,43.6532,-79.3832
relay.cypherflow.ai,48.8575,2.35138
nostr.snowbla.de:443,50.4754,12.3683
nostr.whitenode45.ddns.net,40.55,-74.4758
adre.su,59.9311,30.3609
relay.getsafebox.app,43.6532,-79.3832
auth.nostr1.com,40.7057,-74.0136
relay2.veganostr.com,60.1699,24.9384
relay.i9.eti.br,43.6532,-79.3832
relay.satsmarkt.club,52.6907,4.8181
nostr.bitcoiner.social:443,47.6743,-117.112
cache.trustr.ing,43.6548,-79.3885
relay.mitchelltribe.com,39.0438,-77.4874
21milionidinostr.duckdns.org,41.8967,12.4822
public.crostr.com:443,43.6532,-79.3832
relay.otrta.me,50.1109,8.68213
relay.bornheimer.app,50.1109,8.68213
relay.sigit.io,50.4754,12.3683
nostr-relay.xbytez.io,50.6924,3.20113
relayone.soundhsa.com,39.0997,-94.5786
vm-1734.lnvps.cloud,53.3498,-6.26031
nrs-01.darkcloudarcade.com:443,39.0997,-94.5786
nostr.twinkle.lol,51.902,7.6657
nostr.data.haus,50.4754,12.3683
nostr.mas-family.eu,60.3478,15.7505
relay.nostrhub.fr,48.1045,11.6004
nostr.openhoofd.nl,51.5717,3.70417
relay.nostreon.com,60.1699,24.9384
relay.tdw.lol,41.8781,-87.6298
top.testrelay.top,43.6532,-79.3832
syb.lol,34.0549,-118.243
relay.primal.net,43.6532,-79.3832
n.musicroadmap.com,35.694,139.754
ribo.eu.nostria.app,43.6532,-79.3832
nostr.red5d.dev,43.6532,-79.3832
nostr.ltd,19.076,72.8777
relay.olas.app,60.1699,24.9384
chat-relay.zap-work.com,43.6532,-79.3832
relay.islandbitcoin.com,12.8498,77.6545
relay.chatbett.de,40.7128,-74.006
relay.novospes.com,43.6532,-79.3832
nostr.bond,50.1109,8.68213
inbox.scuba323.com,40.8218,-74.45
0x-nostr-relay.fly.dev,38.7946,-106.535
relay.sincensura.org,43.6532,-79.3832
nostr-02.uid.ovh,50.9871,2.12554
relay.solomonstr.com,43.6532,-79.3832
purplerelay.com,43.6532,-79.3832
nostr.emanuelemiani.it,45.778,8.79414
relay.damustr.com,43.6532,-79.3832
fanfares.nostr1.com,40.7057,-74.0136
relay.plebeian.market:443,50.1109,8.68213
buzz.ac2n-share.kozow.com,45.764,4.83566
nostr-01.yakihonne.com:443,1.32123,103.695
relay.snotr.nl:49999,51.9758,4.31389
relay.ltgnet.work:8443,51.0511,-114.075
nostras.app,38.7946,-106.535
purplerelay.com:443,43.6532,-79.3832
relay.bnos.space,43.6532,-79.3832
porchlight.social,43.6532,-79.3832
relay.mostr.pub:443,43.6532,-79.3832
relay.layer.systems,49.0291,8.35695
nostrcity-club.fly.dev,38.7946,-106.535
relay.guggero.org,46.5971,9.59652
relay.layer.systems:443,49.0291,8.35695
relay.edufeed.org,49.4521,11.0767
nostr.mom:443,50.4754,12.3683
relay.mccormick.cx,52.3563,4.95714
public.crostr.com,43.6532,-79.3832
mostro-p2p.tech,50.1109,8.68213
strfry.shock.network:443,39.0438,-77.4874
relay.agentry.com,42.8864,-78.8784
relay-can.zombi.cloudrodion.com,43.6532,-79.3832
relay.nostrian-conquest.com,41.223,-111.974
relay.fountain.fm,43.6532,-79.3832
nosflare-leefcore.leefcore.workers.dev,43.6532,-79.3832
rele.speyhard.fi,50.1109,8.68213
reraw.pbla2fish.cc,43.6532,-79.3832
relay.pyramid.li,47.4093,8.46503
relay.trotters.cc,43.6532,-79.3832
relay.ru.ac.th,13.7607,100.627
relay.orly.dev,32.7767,-96.797
nostr.yutakobayashi.com,43.6532,-79.3832
nostr-01.yakihonne.com,1.32123,103.695
relay.lanavault.space,60.1699,24.9384
chat-relay.zap-work.com:443,43.6532,-79.3832
strfry.shock.network,39.0438,-77.4874
wheat.happytavern.co,43.6532,-79.3832
relay.klabo.world,47.674,-122.122
relay.littlebitstudios.com,43.6532,-79.3832
nostr-relay.corb.net,39.6478,-104.988
relay.flashapp.me,43.6548,-79.3885
espelho.girino.org,43.6532,-79.3832
budabit.nostr1.com,40.7057,-74.0136
nostr.vulpem.com,49.4543,11.0746
relay.nostrfeed.com,60.1699,24.9384
basspistol.org,49.0291,8.35696
relay-rpi.edufeed.org,49.4521,11.0767
relay.mitchelltribe.com:443,39.0438,-77.4874
spamspamspamspam.rest,43.6532,-79.3832
relayrs.notoshi.win:443,43.6532,-79.3832
relay.endfiat.money,59.3327,18.0656
nostr-rs-relay.dev.fedibtc.com:443,39.0438,-77.4874
nostr.tagomago.me,42.3601,-71.0589
nostr-kyomu-haskell.onrender.com,37.7775,-122.397
nostr.rtvslawenia.com:443,49.4543,11.0746
no.str.cr:443,8.96171,-83.5246
relay.ditto.pub,43.6532,-79.3832
relay.shadowbip.com,54.352,18.6466
nostr.liberty.fans,36.8767,-89.5879
prl.plus,55.7628,37.5983
relay.agorist.space,52.3734,4.89406
articles.layer3.news,37.3387,-121.885
nostr.thalheim.io:443,60.1699,24.9384
relay.mccormick.cx:443,52.3563,4.95714
support.flotilla.social,32.9483,-96.7299
nostr.mikoshi.de,50.1109,8.68213
nostrmxn.lulus.com.mx,59.4016,17.9455
bridge.tagomago.me,42.3601,-71.0589
relayrs.notoshi.win,43.6532,-79.3832
nostr.88mph.life,52.1941,-2.21905
nostr.fullstackcash.net,45.5201,-122.99
relay5.bitransfer.org,43.6532,-79.3832
relay.0xchat.com,43.6532,-79.3832
relay.nostriot.com,41.5695,-83.9786
relay.wellorder.net,45.5201,-122.99
relay-fra.zombi.cloudrodion.com,48.8566,2.35222
nostr.relay.hedwig.sh,60.1699,24.9384
relay.openspecs.uid.ovh,50.9871,2.12554
relay.keykeeper.world,40.7824,-74.0711
nostr.wecsats.io:443,43.6532,-79.3832
relay.wavlake.com,41.2619,-95.8608
relay.lacrypta.ar,43.6532,-79.3832
nos.lol:443,50.4754,12.3683
dm-test-strfry-discovery.samt.st:443,43.6532,-79.3832
relay.nmail.li,50.9871,2.12554
relay.mostro.network,40.8302,-74.1299
bitcoinostr.duckdns.org,41.1976,1.11167
relay.s-w.art,43.6532,-79.3832
relay.ohstr.com:443,43.6532,-79.3832
nostr-dev.wellorder.net,45.5201,-122.99
nostr.notribe.net,40.8302,-74.1299
relay.mleku.dev,32.7767,-96.797
nostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874
nostr2.girino.org:443,43.6532,-79.3832
relay.cosmicbolt.net:443,37.3986,-121.964
ribo.us.nostria.app,43.6532,-79.3832
nostr.janx.com,43.6532,-79.3832
familiamartins.net.br,-22.8833,-43.1036
relay.yoinekodo.jp,43.6532,-79.3832
nostr.4rs.nl,49.0291,8.35696
relay.routstr.com,59.4016,17.9455
relay.bitmacro.cloud,43.6532,-79.3832
relay.mappingbitcoin.com,43.6532,-79.3832
nostr.dlcdevkit.com:443,40.0992,-83.1141
relay-testnet.k8s.layer3.news,37.3387,-121.885
nostr.overmind.lol:443,43.6532,-79.3832
relayone.geektank.ai:443,39.0997,-94.5786
relay.staging.plebeian.market:443,51.5072,-0.127586
relay.kilombino.com,43.6532,-79.3832
relay.endfiat.money:443,59.3327,18.0656
social.amanah.eblessing.co,48.1046,11.6002
nostr.2b9t.xyz,34.0549,-118.243
relay.sector01.com,41.4513,-81.7021
relay-dev.gulugulu.moe:443,43.6532,-79.3832
relay.nostr.dev.br,48.1046,11.6002
x.kojira.io,43.6532,-79.3832
node.kommonzenze.de,49.4521,11.0767
relay.earthly.city,34.1749,-118.54
buzz.cashu.space,50.1109,8.68213
relay.kizuna-miki.com,35.694,139.754
bitchat.nostr1.com,40.7057,-74.0136
bruh.samt.st,43.6532,-79.3832
relay2.fiatdenier.com,50.1013,8.62643
nostr.oxtr.dev,50.4754,12.3683
relay.pocketstr.com,40.8302,-74.1299
nostr.thalheim.io,60.1699,24.9384
relay.qstr.app,50.1109,8.68213
ynostr.yael.at,60.1699,24.9384
nostr.stakey.net,52.3676,4.90414
espelho.girino.org,43.6532,-79.3832
nostr.snowbla.de:443,50.4754,12.3683
hasky.chat,40.7862,-74.0743
relay.plebeian.market,50.1109,8.68213
relay.zone667.com,60.1699,24.9384
cs-relay.nostrdev.com:443,50.4754,12.3683
relay.staging.plebeian.market:443,51.5072,-0.127586
relay.openresist.com,43.6532,-79.3832
nostr.spaceshell.xyz,43.6532,-79.3832
vault.iris.to:443,43.6532,-79.3832
nostr.easycryptosend.it,43.6532,-79.3832
nostr.bitcoiner.social:443,47.6743,-117.112
relay.fiatdenier.com,43.7787,-79.5393
relay.gulugulu.moe:443,43.6532,-79.3832
relay.mitosystem.net,-23.5558,-46.6396
relay.mostro.network,40.8302,-74.1299
relay1.nostrchat.io,60.1699,24.9384
nostr.tabordalab.com,60.1699,24.9384
relay.gulugulu.moe,43.6532,-79.3832
pocketrelay.live,40.8302,-74.1299
relay.nostrfeed.com,60.1699,24.9384
testr.nymble.world,40.8054,-74.0241
x.kojira.io,43.6532,-79.3832
relay.sigit.io,50.4754,12.3683
nostrcity-club.fly.dev:443,38.7946,-106.535
relay.wisp.talk:443,49.4543,11.0746
relay-testnet.k8s.layer3.news,37.3387,-121.885
nostr-dev.wellorder.net,45.5201,-122.99
nostr.pbfs.io:443,50.4754,12.3683
nostr.takasaki.dev,43.6532,-79.3832
relay.snort.social,53.3498,-6.26031
articles.layer3.news,37.3387,-121.885
staging.yabu.me,35.6092,139.73
nostr.rtvslawenia.com:443,49.4543,11.0746
nostr-relay.xbytez.io:443,50.6924,3.20113
public.crostr.com:443,43.6532,-79.3832
nostr.ltd,19.076,72.8777
nostr-02.uid.ovh,50.9871,2.12554
nostr.islandarea.net:443,35.4669,-97.6473
relay.kilombino.com,43.6532,-79.3832
relay.satlantis.io,39.0438,-77.4874
nostr.computingcache.com,45.5341,-122.956
nostr.wecsats.io,43.6532,-79.3832
nostr-relay.zimage.com,34.282,-118.439
nostr.tbxnetworx.de,47.8226,10.0064
relay-can.zombi.cloudrodion.com,43.6532,-79.3832
relay.klabo.world,47.674,-122.122
auth.nostr1.com,40.7057,-74.0136
relay.degmods.com,50.4754,12.3683
relay.olas.app,60.1699,24.9384
fanfares.nostr1.com,40.7057,-74.0136
node.kommonzenze.de,49.4521,11.0767
relay.bitos.space,43.6532,-79.3832
relay01.lnfi.network,35.6764,139.65
relay.froth.zone,60.1699,24.9384
denimroad.feeds.relay.tools,38.6327,-90.1961
nostr.myshosholoza.co.za,52.3676,4.90414
21milionidinostr.duckdns.org,41.8945,12.6493
relay.getvia.xyz,60.1699,24.9384
ribo.us.nostria.app,43.6532,-79.3832
relay.nostrhub.fr,48.1045,11.6004
talon.quest,43.6532,-79.3832
portal-relay.pareto.space,49.4521,11.0767
relayone.geektank.ai,39.0997,-94.5786
relay.orangesync.tech,40.7128,-74.006
strfry.apps3.slidestr.net,40.4167,-3.70329
relay.mleku.dev,32.7767,-96.797
relay.nostrian-conquest.com,41.223,-111.974
relay.underorion.se,50.1109,8.68213
vault.iris.to,43.6532,-79.3832
schnorr.me,43.6532,-79.3832
relay.bullishbounty.com:443,43.6532,-79.3832
relay.i9.eti.br,43.6532,-79.3832
nostr-pub.wellorder.net,45.5201,-122.99
relay.cyberguy.fyi,52.6907,4.8181
relay.mappingbitcoin.com,43.6532,-79.3832
nostr-relay.acloud.kdns.fr,43.6532,-79.3832
nostr.2b9t.xyz,34.0549,-118.243
nostr.2b9t.xyz:443,34.0549,-118.243
dev-relay.nostreon.com,60.1699,24.9384
nostr.purpura.cloud,43.6532,-79.3832
nos.lol:443,50.4754,12.3683
nostr-relay.corb.net,39.6478,-104.988
relay.scuba323.com,40.8218,-74.45
relay.agora.social,50.7383,15.0648
nostr.data.haus,50.4754,12.3683
relay.satsapp.me,50.4754,12.3683
nostr-rs-relay-ishosta.phamthanh.me,43.6532,-79.3832
relay.beamhop.com,43.6532,-79.3832
relay.paulstephenborile.com,49.4543,11.0746
nostr.easycryptosend.it,43.6532,-79.3832
nostr-verified.wellorder.net,45.5201,-122.99
nostr.hekster.org:443,37.3986,-121.964
relay.btcforplebs.com,43.6532,-79.3832
relay-arg.zombi.cloudrodion.com,1.35208,103.82
nostr.wild-vibes.ts.net,48.8566,2.35222
relay.bornheimer.app,50.1109,8.68213
relay02.lnfi.network,35.6764,139.65
relay.kaleidoswap.com,50.8476,4.35717
strfry.shock.network,39.0438,-77.4874
nostr.txlyre.website,60.1699,24.9384
nostr.twinkle.lol,51.902,7.6657
relay.satmaxt.xyz,43.6532,-79.3832
relay.vrtmrz.net:443,43.6532,-79.3832
aurum.saturnali.net,46.2044,6.14316
nostrrelay.taylorperron.com,45.5029,-73.5723
nostrelay.circum.space,52.2245,8.826
relay.mostr.pub:443,43.6532,-79.3832
nostr.data.haus:443,50.4754,12.3683
relay.nostreon.com,60.1699,24.9384
relay.plebeian.market:443,50.1109,8.68213
relay.damus.io,43.6532,-79.3832
budabit.nostr1.com,40.7057,-74.0136
bucket.coracle.social,37.7775,-122.397
relay.chorus.community:443,50.7529,6.84585
relay.hivetalk.org,40.8302,-74.1299
relay.favillakey.io,-27.4705,153.026
nostr.mom,50.4754,12.3683
relay2.orangesync.tech,40.7128,-74.006
relay.artiostr.ch,43.6532,-79.3832
relay.lacrypta.ar,43.6532,-79.3832
treuzkas.branruz.com,48.8575,2.35138
relay2.veganostr.com,60.1699,24.9384
borgar.lol,52.3676,4.90414
relay.ltgnet.work:8443,51.0511,-114.075
relay.nostrmap.net,60.1699,24.9384
nostr.mom:443,50.4754,12.3683
relay.decentralia.fr,48.122,11.589
relay.atsocy.com,43.6532,-79.3832
strfry.shock.network:443,39.0438,-77.4874
relay.vrtmrz.net,43.6532,-79.3832
0x-nostr-relay.fly.dev,38.7946,-106.535
relay.veganostr.com,60.1699,24.9384
relay.nostr.bond,40.1885,29.061
relay.angor.io,48.1046,11.6002
nostr.sathoarder.com:443,48.5734,7.75211
nostr.oxtr.dev,50.4754,12.3683
dev.relay.stream,43.6532,-79.3832
relay.mitchelltribe.com,39.0438,-77.4874
nostr-relay.cbrx.io,43.6532,-79.3832
nostr2.girino.org:443,43.6532,-79.3832
relay.snotr.nl:49999,51.9758,4.31389
n.musicroadmap.com,35.694,139.754
relay.loveisbitcoin.com,43.6532,-79.3832
relay.conduit.market,38.7946,-106.535
schnorr.me:443,43.6532,-79.3832
nostr-kyomu-haskell.onrender.com,37.7775,-122.397
nostr.dlcdevkit.com,40.0992,-83.1141
relay2.angor.io,48.1046,11.6002
nostr.hifish.org,47.4009,8.57814
rele.speyhard.fi,51.5072,-0.127586
nostr.chaima.info:443,51.5072,-0.127586
relay.trotters.cc:443,43.6532,-79.3832
nostrmxn.lulus.com.mx,59.4016,17.9455
chat-relay.zap-work.com:443,43.6532,-79.3832
relay.tdw.gg,34.0549,-118.243
relay.ohstr.com,43.6532,-79.3832
relay.guggero.org,46.5971,9.59652
nostr.stakey.net:443,52.3676,4.90414
dev-relay.nostr.space,43.6532,-79.3832
nostr.snowbla.de,50.4754,12.3683
relay.piazza.today,48.122,11.589
relay.fountain.fm:443,43.6532,-79.3832
relay.samt.st,40.8302,-74.1299
nostr.islandarea.net:443,35.4669,-97.6473
nostr-01.uid.ovh,50.9871,2.12554
relay-dev.gulugulu.moe,43.6532,-79.3832
relay.ditto.pub:443,43.6532,-79.3832
vm-1734.lnvps.cloud,53.3498,-6.26031
relay.beginningend.com,35.2227,-97.4786
nos.lol,50.4754,12.3683
nostr-01.yakihonne.com:443,1.32123,103.695
relay.sigit.io:443,50.4754,12.3683
nostr.janx.com,43.6532,-79.3832
relay.wavlake.com,41.2619,-95.8608
relayrs.notoshi.win:443,43.6532,-79.3832
openrelay.ziomc.com,45.4642,9.18998
nostr.88mph.life,52.1941,-2.21905
maxq.descendant.io,43.6532,-79.3832
nostr.wecsats.io:443,43.6532,-79.3832
relay.bitmacro.cloud,43.6532,-79.3832
relay.lightning.pub,39.0438,-77.4874
strfry.bonsai.com,39.0438,-77.4874
chat-relay.zap-work.com,43.6532,-79.3832
relay.illuminodes.com,43.6532,-79.3832
nostr.thebiglake.org,32.71,-96.6745
ribo.eu.nostria.app,43.6532,-79.3832
relay.novospes.com,43.6532,-79.3832
nostr.notribe.net,40.8302,-74.1299
nostr-relay.corb.net:443,39.6478,-104.988
relay.pprgb.app,52.52,13.405
relay.wellorder.net,45.5201,-122.99
relay.mitchelltribe.com:443,39.0438,-77.4874
cyberspace.nostr1.com,40.7057,-74.0136
bruh.samt.st,43.6532,-79.3832
relay.44billion.net,43.6532,-79.3832
dev-relay.nostreon.com,60.1699,24.9384
nostr.pbfs.io,50.4754,12.3683
nrs-01.darkcloudarcade.com,39.0997,-94.5786
relay01.lnfi.network,35.6764,139.65
relay.mostr.pub,43.6532,-79.3832
strfry.apps3.slidestr.net,40.4167,-3.70329
nostr.linky.fit,50.1109,8.68213
nostr.davenov.com,50.1109,8.68213
nostr.sovereignservices.xyz,43.6532,-79.3832
relay-dev.gulugulu.moe:443,43.6532,-79.3832
nostr.unkn0wn.world,46.8499,9.53287
relay.nostr.space,43.6532,-79.3832
infinity-signal-relay.digitalforlifeagency.workers.dev,43.6532,-79.3832
relay.ezernode.net,52.6921,6.19372
no.str.cr,8.96171,-83.5246
spamspamspamspam.rest,43.6532,-79.3832
relay.ru.ac.th,13.7607,100.627
relay.tdw.lol,41.8781,-87.6298
buzz.cashu.space,50.1109,8.68213
relay.duck1123.com,43.6532,-79.3832
relay.tapestry.ninja,40.8054,-74.0241
relay.layer.systems,49.0291,8.35695
relay.dyne.org,49.0291,8.35705
relay.aidatanorge.no,43.6532,-79.3832
relay.opmaat.org,60.1699,24.9384
nostr.azzamo.net,52.2633,21.0283
relay.mccormick.cx,52.3563,4.95714
relay.nostr.com,50.1109,8.68213
nostr.plantroon.com,50.1013,8.62643
support.flotilla.social,32.9483,-96.7299
relay.veganostr.com:443,60.1699,24.9384
relay.notoshi.win,12.9333,100.883
relay.edufeed.org,49.4521,11.0767
memlay.v0l.io,53.3498,-6.26031
bitcoiner.social,47.6743,-117.112
relay.noeudlibre.fr,50.6924,3.20113
hasky.chat,40.7862,-74.0743
relay.artio.inf.unibe.ch,46.9501,7.43678
relay.manneken.brussels,49.4543,11.0746
nosflare-leefcore.leefcore.workers.dev,43.6532,-79.3832
no.str.cr:443,8.96171,-83.5246
relay.veganostr.com,60.1699,24.9384
relay.nexterz.com,43.6532,-79.3832
nostr.snowbla.de,50.4754,12.3683
nostr.unkn0wn.world,46.8499,9.53287
nostr.wild-vibes.ts.net,48.8566,2.35222
relay.bitos.space,43.6532,-79.3832
relay-na1.metanomalist.com,43.6532,-79.3832
relay.favillakey.io,-27.4705,153.026
relay.nearhood.co.uk,51.5134,-0.0890675
relay.illuminodes.com,43.6532,-79.3832
nostr.dlcdevkit.com,40.0992,-83.1141
relay.nostr.net,43.6532,-79.3832
relay.trotters.cc:443,43.6532,-79.3832
cyberspace.nostr1.com,40.7057,-74.0136
nostr-relay.nextblockvending.com,47.2343,-119.853
relay.btcforplebs.com,43.6532,-79.3832
nostr.azzamo.net:443,52.2633,21.0283
relay.tdw.gg,34.0549,-118.243
relay.veganostr.com:443,60.1699,24.9384
wisp.djorivaltech.com,43.6532,-79.3832
relay.cosmicbolt.net,37.3986,-121.964
nostr.debate.report,50.1109,8.68213
nostr.na.social,43.6532,-79.3832
relay.kaleidoswap.com,50.8476,4.35717
nostr.sathoarder.com:443,48.5734,7.75211
relay.getsafebox.app:443,43.6532,-79.3832
relay.aarpia.com,37.3986,-121.964
nostr.sathoarder.com,48.5734,7.75211
relay.getsafebox.app,43.6532,-79.3832
nostr.chaima.info,51.5072,-0.127586
relay.ditto.pub,43.6532,-79.3832
nostr.4rs.nl,49.0291,8.35696
rilo.nostria.app,43.6532,-79.3832
nostr.christiansass.de,51.7883,6.13865
relay.satsapp.me,50.4754,12.3683
relay.decentralia.fr,48.122,11.589
relay.beginningend.com,35.2227,-97.4786
relay.paulstephenborile.com:443,49.4543,11.0746
relay.earthly.city,34.1749,-118.54
inbox.scuba323.com,40.8218,-74.45
nostr-01.yakihonne.com,1.32123,103.695
articles.layer3.news:443,37.3387,-121.885
offchain.pub,39.1585,-94.5728
strfry.bonsai.com:443,39.0438,-77.4874
relay.nostriot.com,41.5695,-83.9786
relay.bullishbounty.com,43.6532,-79.3832
dm-test-strfry-generic.samt.st,43.6532,-79.3832
relay.lanacoin-eternity.com:443,40.8302,-74.1299
dm-test-strfry-discovery.samt.st,43.6532,-79.3832
nostr.emanuelemiani.it,45.778,8.79414
reraw.pbla2fish.cc,43.6532,-79.3832
relay.s-w.art,43.6532,-79.3832
relay.nostrcheck.me,43.6532,-79.3832
relay.npubhaus.com,43.6532,-79.3832
nostr.vulpem.com,49.4543,11.0746
relay.wasabiwallet.io,43.6532,-79.3832
nostr.liberty.fans,36.8767,-89.5879
relay.paulstephenborile.com,49.4543,11.0746
relay.nostrmap.net:443,60.1699,24.9384
tribune-panel-growing-noon.trycloudflare.com,43.6532,-79.3832
relay-na1.metanomalist.com,43.6532,-79.3832
relay.agentry.com,42.8864,-78.8784
relay.samt.st,40.8302,-74.1299
relay.nostx.io,43.6532,-79.3832
relay.cosmicbolt.net:443,37.3986,-121.964
relayrs.notoshi.win,43.6532,-79.3832
adre.su,59.9311,30.3609
nostrcheck.me,43.6532,-79.3832
relay-us.zombi.cloudrodion.com,40.7862,-74.0743
nrs-01.darkcloudarcade.com,39.0997,-94.5786
relay.nmail.li,50.9871,2.12554
relay.wisp.talk,49.4543,11.0746
relay.mostr.pub,43.6532,-79.3832
relay.jmoose.rocks,32.8009,-96.8195
relay.laantungir.net,-19.4692,-42.5315
nostr.nodesmap.com,59.3327,18.0656
freelay.sovbit.host,60.1699,24.9384
relay.satmaxt.xyz,43.6532,-79.3832
nostr.oxtr.dev:443,50.4754,12.3683
vault.iris.to,43.6532,-79.3832
relay.ditto.pub:443,43.6532,-79.3832
relay.atsocy.com,43.6532,-79.3832
relay.vrtmrz.net,43.6532,-79.3832
soloco.nl,43.6532,-79.3832
ribo.nostria.app,43.6532,-79.3832
relay.chatbett.de,40.7128,-74.006
groups.beginningend.com,35.2227,-97.4786
relay.chorus.community,50.7529,6.84585
wisp.djorivaltech.com,43.6532,-79.3832
nostr-02.yakihonne.com,1.32123,103.695
nostr-rs-relay-ishosta.phamthanh.me,43.6532,-79.3832
relayone.soundhsa.com:443,39.0997,-94.5786
nostr.wawasoft.net,43.6532,-79.3832
relay.solife.me,43.6532,-79.3832
nostr.mom,50.4754,12.3683
relay.getvia.xyz,60.1699,24.9384
freelay.sovbit.host,60.1699,24.9384
relay-rpi.edufeed.org:443,49.4521,11.0767
relay.bullishbounty.com:443,43.6532,-79.3832
schnorr.me:443,43.6532,-79.3832
temp.iris.to,43.6532,-79.3832
nostr-relay.cbrx.io,43.6532,-79.3832
strfry.bonsai.com:443,39.0438,-77.4874
nostr.hoppe-relay.it.com,42.8864,-78.8784
dm-test-nostr-rs-42-disabled.samt.st,43.6532,-79.3832
relay.nostriches.club,43.6532,-79.3832
relay.dyne.org,49.0291,8.35705
yabu.me,35.6092,139.73
relay.chorus.community:443,48.5333,10.7
cs-relay.nostrdev.com:443,50.4754,12.3683
relay.mwaters.net,50.9871,2.12554
no.str.cr,8.96171,-83.5246
nostr.hifish.org,47.4009,8.57814
relay.directsponsor.net,42.8864,-78.8784
public.obelisk.ar,43.6532,-79.3832
nostr.carroarmato0.be,50.914,3.21378
nostr.sovereignservices.xyz,43.6532,-79.3832
nostr.pbfs.io:443,50.4754,12.3683
relay.degmods.com,50.4754,12.3683
relay.plebeian.market,50.1109,8.68213
offchain.pub,39.1585,-94.5728
nostr.thebiglake.org,32.71,-96.6745
bendernostur.duckdns.org:8443,50.1109,8.68213
nostr-relay.zimage.com,34.282,-118.439
nostr.ac,38.958,-77.3592
relay.nostr.com,50.1109,8.68213
bitcoiner.social,47.6743,-117.112
nostr.wecsats.io,43.6532,-79.3832
relay.pprgb.app,52.52,13.405
nostr.girino.org,43.6532,-79.3832
nostr.bitcoiner.social,47.6743,-117.112
nostr.planix.org,43.6532,-79.3832
nostr.data.haus:443,50.4754,12.3683
relay.nostrmap.net:443,60.1699,24.9384
relay.arx-ccn.com,50.4754,12.3683
relay.agorist.space:443,52.3734,4.89406
relay.laantungir.net,-19.4692,-42.5315
relay.edufeed.org:443,49.4521,11.0767
nostrcity-club.fly.dev:443,38.7946,-106.535
nostr.nodesmap.com,59.3327,18.0656

1 Relay URL Latitude Longitude
2 myvoiceourstory.org dm-test-strfry-generic.samt.st 37.3598 43.6532 -121.981 -79.3832
nostr.whitenode45.ddns.net 40.55 -74.4758
relay5.bitransfer.org 43.6532 -79.3832
relay.littlebitstudios.com 43.6532 -79.3832
public.crostr.com 43.6532 -79.3832
nostr-verified.wellorder.net 45.5201 -122.99
nostr-relay.xbytez.io 50.6924 3.20113
nostr.azzamo.net:443 52.2633 21.0283
yabu.me 35.6092 139.73
relay.arx-ccn.com 50.4754 12.3683
relay.ohstr.com:443 43.6532 -79.3832
relay.nearhood.co.uk 51.5134 -0.0890675
purplerelay.com 43.6532 -79.3832
cs-relay.nostrdev.com 50.4754 12.3683
nostr.red5d.dev 43.6532 -79.3832
relay-fra.zombi.cloudrodion.com 48.8566 2.35222
relay.lightning.pub:443 39.0438 -77.4874
relay.mccormick.cx:443 52.3563 4.95714
fanfares.nostr1.com:443 40.7057 -74.0136
relay.shadowbip.com 54.352 18.6466
nip85.nosfabrica.com 39.0997 -94.5786
social.amanah.eblessing.co 48.1046 11.6002
relay.openspecs.uid.ovh 50.9871 2.12554
relay.pyramid.li 47.4093 8.46503
bcast.girino.org 43.6532 -79.3832
relay.jmoose.rocks:443 32.8009 -96.8195
relay.solife.me 43.6532 -79.3832
relay.flashapp.me 43.6548 -79.3885
relay.wavlake.com:443 41.2619 -95.8608
nostr.plantroon.com:443 50.1013 8.62643
relay.lanacoin-eternity.com 40.8302 -74.1299
nostr.infero.net 35.6764 139.65
3 nostrelay.circum.space:443 52.2245 8.826
dm-test-nostr-rs-42-disabled.samt.st 43.6532 -79.3832
relay2.fiatdenier.com 50.1013 8.62643
nostr.l484.com 30.2672 -97.7431
relay.nostr.net 43.6532 -79.3832
relay.nuts.cash 52.3676 4.90414
nostr.bond 50.1109 8.68213
nostr.debate.report 50.1109 8.68213
prl.plus 55.7558 37.6173
temp.iris.to 43.6532 -79.3832
relay.noeudlibre.fr 50.6924 3.20113
relay.routstr.com 59.4016 17.9455
relay.islandbitcoin.com 20.3898 78.0903
nostr.linky.fit 50.1109 8.68213
bitchat.nostr1.com 40.7057 -74.0136
relay.lanavault.space 60.1699 24.9384
relayone.geektank.ai:443 39.0997 -94.5786
nostr-rs-relay.dev.fedibtc.com 39.0438 -77.4874
relay.kizuna-miki.com 35.694 139.754
relay.edufeed.org:443 49.4521 11.0767
nr.yay.so 46.2126 6.1154
nostr.oxtr.dev:443 50.4754 12.3683
relay.primal.net 43.6532 -79.3832
4 relay.staging.plebeian.market 51.5072 -0.127586
5 nostr.planix.org nostr.chaima.info:443 43.6532 50.1109 -79.3832 8.68213
6 relay.artio.inf.unibe.ch relay.vrtmrz.net:443 46.9501 43.6532 7.43678 -79.3832
7 chorus.mikedilger.com:444 relay.gulugulu.moe:443 -36.8906 43.6532 174.794 -79.3832
nrs-01.darkcloudarcade.com:443 39.0997 -94.5786
relay.pocketstr.com 40.8302 -74.1299
nostr.middling.mydns.jp 35.8099 140.12
offchain.pub:443 39.1585 -94.5728
cache.trustr.ing 43.6548 -79.3885
nostrride.io 37.3986 -121.964
relay.nostrfy.org 35.6916 139.768
wheat.happytavern.co 43.6532 -79.3832
relayone.soundhsa.com:443 39.0997 -94.5786
relay.satsmarkt.club 52.6907 4.8181
nexus.libernet.app 43.6532 -79.3832
relay.directsponsor.net 42.8864 -78.8784
nostr.spicyz.io 43.6532 -79.3832
nostr.rtvslawenia.com 49.4543 11.0746
relay.trotters.cc 43.6532 -79.3832
relayone.soundhsa.com 39.0997 -94.5786
nostr.na.social 43.6532 -79.3832
ribo.us.nostria.app:443 43.6532 -79.3832
nostr.hekster.org 37.3986 -121.964
relay.nostr.dev.br 48.1046 11.6002
relay.keykeeper.world 40.7824 -74.0711
nostr.pbfs.io 50.4754 12.3683
nostr.girino.org 43.6532 -79.3832
relay1.privkey.io 25.7617 -80.1918
relay.sharegap.net 43.6532 -79.3832
8 relay.dreamith.to 43.6532 -79.3832
9 nostr2.girino.org nostrride.io 43.6532 37.3986 -79.3832 -121.964
nostr.bitcoiner.social 47.6743 -117.112
relay.nostu.be 40.4167 -3.70329
relay.mwaters.net 50.9871 2.12554
nostr.islandarea.net 35.4669 -97.6473
chorus.bonsai.com 39.0438 -77.4874
nostr.yutakobayashi.com 43.6532 -79.3832
nostras.app 38.7946 -106.535
relay.yoinekodo.jp 43.6532 -79.3832
top.testrelay.top 43.6532 -79.3832
nostr-01.uid.ovh 50.9871 2.12554
relay.orly.dev 32.7767 -96.797
offchain.bostr.online 43.6532 -79.3832
nostr-rs-relay.dev.fedibtc.com:443 39.0438 -77.4874
conduitl2.fly.dev 38.7946 -106.535
10 nostr.n7ekb.net 47.4941 -122.294
11 relay.44billion.net relay.npubhaus.com 43.6532 -79.3832
12 nostrcity-club.fly.dev relay1.privkey.io 38.7946 25.7617 -106.535 -80.1918
13 syb.lol nostr-relay.xbytez.io:443 34.0549 50.6924 -118.243 3.20113
14 relay.mypathtofire.de relay.bullishbounty.com 42.8864 43.6532 -78.8784 -79.3832
15 nostr.relay.hedwig.sh nostr.islandarea.net 60.1699 35.4669 24.9384 -97.6473
16 nostr.myshosholoza.co.za:443 nostrrelay.taylorperron.com 52.3676 45.5029 4.90414 -73.5723
17 relay.sharegap.net 43.6532 -79.3832
18 relayone.geektank.ai 39.0997 -94.5786
19 nexus.libernet.app 43.6532 -79.3832
20 fanfares.nostr1.com:443 40.7057 -74.0136
21 relay.satlantis.io 39.0438 -77.4874
22 relay.hivetalk.org 40.8302 -74.1299
23 nos.lol 50.4754 12.3683
24 staging.yabu.me 35.6092 139.73
25 relay-us.zombi.cloudrodion.com 40.7862 -74.0743
26 relay.internationalright-wing.org -22.4692 -48.9875
27 relay.nostrcheck.me 43.6532 -79.3832
28 denimroad.feeds.relay.tools 38.6327 -90.1961
29 relay.nostr-check.me 43.6532 -79.3832
30 ribo.us.nostria.app:443 43.6532 -79.3832
31 relay.lightning.pub 39.0438 -77.4874
32 myvoiceourstory.org 37.3598 -121.981
33 relay.jmoose.rocks:443 37.4419 -122.143
34 nostr-pr02.redscrypt.org 52.3676 4.90414
35 nostr.purpura.cloud 43.6532 -79.3832
36 relay.sigit.io:443 50.4754 12.3683
37 relay.nostrops.com 32.71 -96.6745
38 relay.jmoose.rocks 37.4419 -122.143
39 nostr.spaceshell.xyz 43.6532 -79.3832
40 nostr-pub.wellorder.net 45.5201 -122.99
41 relay.opmaat.org 60.1699 24.9384
42 nostr.myshosholoza.co.za 52.3676 4.90414
43 offchain.bostr.online 43.6532 -79.3832
44 nostr-relay.nilpote.com 43.6532 -79.3832
45 relay.nostu.be 40.4167 -3.70329
46 nostr.stakey.net 52.3676 4.90414
47 dm-test-strfry-discovery.samt.st 43.6532 -79.3832
48 conduitl2.fly.dev 38.7946 -106.535
49 vault.iris.to:443 43.6532 -79.3832
50 relay.minibolt.info 43.6532 -79.3832
51 nostr.fullstackcash.net articles.layer3.news:443 45.5201 37.3387 -122.99 -121.885
52 bendernostur.duckdns.org:8443 nostr.overmind.lol 50.1109 43.6532 8.68213 -79.3832
53 nostr.21crypto.ch 47.5356 8.73209
54 nostr.tabordalab.com 60.1699 24.9384
55 nostr.spicyz.io 43.6532 -79.3832
56 nostr.sathoarder.com 48.5734 7.75211
57 relay.chorus.community 48.5333 10.7
58 dev.relay.stream 43.6532 -79.3832
59 nostr-relay.corb.net:443 39.6478 -104.988
60 relay.lightning.pub:443 39.0438 -77.4874
61 nostr.tbxnetworx.de 47.8226 10.0064
62 nostr.azzamo.net 52.2633 21.0283
63 relay.loveisbitcoin.com 43.6532 -79.3832
64 strfry.ymir.cloud 43.6532 -79.3832
65 chorus.mikedilger.com:444 -36.8906 174.794
66 relay.agilesolutionlabs.com 43.6532 -79.3832
67 relay.gulugulu.moe 43.6532 -79.3832
68 nostr.tac.lol 47.4748 -122.273
69 relay.mypathtofire.de 42.8864 -78.8784
70 nostrelay.circum.space 52.2245 8.826
71 bcast.girino.org 43.6532 -79.3832
72 relay.ordoplay.com 50.1109 8.68213
73 nostr.chaima.info 50.1109 8.68213
74 offchain.pub:443 39.1585 -94.5728
75 relay.wisp.talk 49.4543 11.0746
76 strfry.bonsai.com 39.0438 -77.4874
77 nostr.2b9t.xyz:443 34.0549 -118.243
78 relay.nostx.io 43.6532 -79.3832
79 bucket.coracle.social 37.7775 -122.397
80 relay.wavlake.com:443 41.2619 -95.8608
81 nostr.carroarmato0.be:443 50.914 3.21378
82 treuzkas.branruz.com 48.8575 2.35138
83 nostr.computingcache.com 45.5341 -122.956
84 cs-relay.nostrdev.com 50.4754 12.3683
85 relay.lanacoin-eternity.com 40.8302 -74.1299
86 nostr.rtvslawenia.com 49.4543 11.0746
87 nip85.nosfabrica.com 39.0997 -94.5786
88 relay.wisp.talk:443 49.4543 11.0746
89 nostr.stakey.net:443 52.3676 4.90414
90 relay.piazza.today 48.122 11.589
91 relay.conduit.market 38.7946 -106.535
92 relay.nostrmap.net 60.1699 24.9384
93 maxq.descendant.io 43.6532 -79.3832
94 nostr.hekster.org 37.3986 -121.964
95 nostr.christiansass.de 49.7423 8.76687
96 tribune-panel-growing-noon.trycloudflare.com 43.6532 -79.3832
97 relay.lanacoin-eternity.com:443 40.8302 -74.1299
98 relay.scuba323.com 40.8218 -74.45
99 schnorr.me 43.6532 -79.3832
100 relay.cypherflow.ai 48.8575 2.35138
101 nostr.snowbla.de:443 50.4754 12.3683
102 nostr.whitenode45.ddns.net 40.55 -74.4758
103 adre.su 59.9311 30.3609
104 relay.getsafebox.app 43.6532 -79.3832
105 auth.nostr1.com 40.7057 -74.0136
106 relay2.veganostr.com 60.1699 24.9384
107 relay.i9.eti.br 43.6532 -79.3832
108 relay.satsmarkt.club 52.6907 4.8181
109 nostr.bitcoiner.social:443 47.6743 -117.112
110 cache.trustr.ing 43.6548 -79.3885
111 relay.mitchelltribe.com 39.0438 -77.4874
112 21milionidinostr.duckdns.org 41.8967 12.4822
113 public.crostr.com:443 43.6532 -79.3832
114 relay.otrta.me 50.1109 8.68213
115 relay.bornheimer.app 50.1109 8.68213
116 relay.sigit.io 50.4754 12.3683
117 nostr-relay.xbytez.io 50.6924 3.20113
118 relayone.soundhsa.com 39.0997 -94.5786
119 vm-1734.lnvps.cloud 53.3498 -6.26031
120 nrs-01.darkcloudarcade.com:443 39.0997 -94.5786
121 nostr.twinkle.lol 51.902 7.6657
122 nostr.data.haus 50.4754 12.3683
123 nostr.mas-family.eu 60.3478 15.7505
124 relay.nostrhub.fr 48.1045 11.6004
125 nostr.openhoofd.nl 51.5717 3.70417
126 relay.nostreon.com 60.1699 24.9384
127 relay.tdw.lol 41.8781 -87.6298
128 top.testrelay.top 43.6532 -79.3832
129 syb.lol 34.0549 -118.243
130 relay.primal.net 43.6532 -79.3832
131 n.musicroadmap.com 35.694 139.754
132 ribo.eu.nostria.app 43.6532 -79.3832
133 nostr.red5d.dev 43.6532 -79.3832
134 nostr.ltd 19.076 72.8777
135 relay.olas.app 60.1699 24.9384
136 chat-relay.zap-work.com 43.6532 -79.3832
137 relay.islandbitcoin.com 12.8498 77.6545
138 relay.chatbett.de 40.7128 -74.006
139 relay.novospes.com 43.6532 -79.3832
140 nostr.bond 50.1109 8.68213
141 inbox.scuba323.com 40.8218 -74.45
142 0x-nostr-relay.fly.dev 38.7946 -106.535
143 relay.sincensura.org 43.6532 -79.3832
144 nostr-02.uid.ovh 50.9871 2.12554
145 relay.solomonstr.com 43.6532 -79.3832
146 purplerelay.com 43.6532 -79.3832
147 nostr.emanuelemiani.it 45.778 8.79414
148 relay.damustr.com 43.6532 -79.3832
149 fanfares.nostr1.com 40.7057 -74.0136
150 relay.plebeian.market:443 50.1109 8.68213
151 buzz.ac2n-share.kozow.com 45.764 4.83566
152 nostr-01.yakihonne.com:443 1.32123 103.695
153 relay.snotr.nl:49999 51.9758 4.31389
154 relay.ltgnet.work:8443 51.0511 -114.075
155 nostras.app 38.7946 -106.535
156 purplerelay.com:443 43.6532 -79.3832
157 relay.bnos.space 43.6532 -79.3832
158 porchlight.social 43.6532 -79.3832
159 relay.mostr.pub:443 43.6532 -79.3832
160 relay.layer.systems 49.0291 8.35695
161 nostrcity-club.fly.dev 38.7946 -106.535
162 relay.guggero.org 46.5971 9.59652
163 relay.layer.systems:443 49.0291 8.35695
164 relay.edufeed.org 49.4521 11.0767
165 nostr.mom:443 50.4754 12.3683
166 relay.mccormick.cx 52.3563 4.95714
167 public.crostr.com 43.6532 -79.3832
168 mostro-p2p.tech 50.1109 8.68213
169 strfry.shock.network:443 39.0438 -77.4874
170 relay.agentry.com 42.8864 -78.8784
171 relay-can.zombi.cloudrodion.com 43.6532 -79.3832
172 relay.nostrian-conquest.com 41.223 -111.974
173 relay.fountain.fm 43.6532 -79.3832
174 nosflare-leefcore.leefcore.workers.dev 43.6532 -79.3832
175 rele.speyhard.fi 50.1109 8.68213
176 reraw.pbla2fish.cc 43.6532 -79.3832
177 relay.pyramid.li 47.4093 8.46503
178 relay.trotters.cc 43.6532 -79.3832
179 relay.ru.ac.th 13.7607 100.627
180 relay.orly.dev 32.7767 -96.797
181 nostr.yutakobayashi.com 43.6532 -79.3832
182 nostr-01.yakihonne.com 1.32123 103.695
183 relay.lanavault.space 60.1699 24.9384
184 chat-relay.zap-work.com:443 43.6532 -79.3832
185 strfry.shock.network 39.0438 -77.4874
186 wheat.happytavern.co 43.6532 -79.3832
187 relay.klabo.world 47.674 -122.122
188 relay.littlebitstudios.com 43.6532 -79.3832
189 nostr-relay.corb.net 39.6478 -104.988
190 relay.flashapp.me 43.6548 -79.3885
191 espelho.girino.org 43.6532 -79.3832
192 budabit.nostr1.com 40.7057 -74.0136
193 nostr.vulpem.com 49.4543 11.0746
194 relay.nostrfeed.com 60.1699 24.9384
195 basspistol.org 49.0291 8.35696
196 relay-rpi.edufeed.org 49.4521 11.0767
197 relay.mitchelltribe.com:443 39.0438 -77.4874
198 spamspamspamspam.rest 43.6532 -79.3832
199 relayrs.notoshi.win:443 43.6532 -79.3832
200 relay.endfiat.money 59.3327 18.0656
201 nostr-rs-relay.dev.fedibtc.com:443 39.0438 -77.4874
202 nostr.tagomago.me 42.3601 -71.0589
203 nostr-kyomu-haskell.onrender.com 37.7775 -122.397
204 nostr.rtvslawenia.com:443 49.4543 11.0746
205 no.str.cr:443 8.96171 -83.5246
206 relay.ditto.pub 43.6532 -79.3832
207 relay.shadowbip.com 54.352 18.6466
208 nostr.liberty.fans 36.8767 -89.5879
209 prl.plus 55.7628 37.5983
210 relay.agorist.space 52.3734 4.89406
211 articles.layer3.news 37.3387 -121.885
212 nostr.thalheim.io:443 60.1699 24.9384
213 relay.mccormick.cx:443 52.3563 4.95714
214 support.flotilla.social 32.9483 -96.7299
215 nostr.mikoshi.de 50.1109 8.68213
216 nostrmxn.lulus.com.mx 59.4016 17.9455
217 bridge.tagomago.me 42.3601 -71.0589
218 relayrs.notoshi.win 43.6532 -79.3832
219 nostr.88mph.life 52.1941 -2.21905
220 nostr.fullstackcash.net 45.5201 -122.99
221 relay5.bitransfer.org 43.6532 -79.3832
222 relay.0xchat.com 43.6532 -79.3832
223 relay.nostriot.com 41.5695 -83.9786
224 relay.wellorder.net 45.5201 -122.99
225 relay-fra.zombi.cloudrodion.com 48.8566 2.35222
226 nostr.relay.hedwig.sh 60.1699 24.9384
227 relay.openspecs.uid.ovh 50.9871 2.12554
228 relay.keykeeper.world 40.7824 -74.0711
229 nostr.wecsats.io:443 43.6532 -79.3832
230 relay.wavlake.com 41.2619 -95.8608
231 relay.lacrypta.ar 43.6532 -79.3832
232 nos.lol:443 50.4754 12.3683
233 dm-test-strfry-discovery.samt.st:443 43.6532 -79.3832
234 relay.nmail.li 50.9871 2.12554
235 relay.mostro.network 40.8302 -74.1299
236 bitcoinostr.duckdns.org 41.1976 1.11167
237 relay.s-w.art 43.6532 -79.3832
238 relay.ohstr.com:443 43.6532 -79.3832
239 nostr-dev.wellorder.net 45.5201 -122.99
240 nostr.notribe.net 40.8302 -74.1299
241 relay.mleku.dev 32.7767 -96.797
242 nostr-rs-relay.dev.fedibtc.com 39.0438 -77.4874
243 nostr2.girino.org:443 43.6532 -79.3832
244 relay.cosmicbolt.net:443 37.3986 -121.964
245 ribo.us.nostria.app 43.6532 -79.3832
246 nostr.janx.com 43.6532 -79.3832
247 familiamartins.net.br -22.8833 -43.1036
248 relay.yoinekodo.jp 43.6532 -79.3832
249 nostr.4rs.nl 49.0291 8.35696
250 relay.routstr.com 59.4016 17.9455
251 relay.bitmacro.cloud 43.6532 -79.3832
252 relay.mappingbitcoin.com 43.6532 -79.3832
253 nostr.dlcdevkit.com:443 40.0992 -83.1141
254 relay-testnet.k8s.layer3.news 37.3387 -121.885
255 nostr.overmind.lol:443 43.6532 -79.3832
256 relayone.geektank.ai:443 39.0997 -94.5786
257 relay.staging.plebeian.market:443 51.5072 -0.127586
258 relay.kilombino.com 43.6532 -79.3832
259 relay.endfiat.money:443 59.3327 18.0656
260 social.amanah.eblessing.co 48.1046 11.6002
261 nostr.2b9t.xyz 34.0549 -118.243
262 relay.sector01.com 41.4513 -81.7021
263 relay-dev.gulugulu.moe:443 43.6532 -79.3832
264 relay.nostr.dev.br 48.1046 11.6002
265 x.kojira.io 43.6532 -79.3832
266 node.kommonzenze.de 49.4521 11.0767
267 relay.earthly.city 34.1749 -118.54
268 buzz.cashu.space 50.1109 8.68213
269 relay.kizuna-miki.com 35.694 139.754
270 bitchat.nostr1.com 40.7057 -74.0136
271 bruh.samt.st 43.6532 -79.3832
272 relay2.fiatdenier.com 50.1013 8.62643
273 nostr.oxtr.dev 50.4754 12.3683
274 relay.pocketstr.com 40.8302 -74.1299
275 nostr.thalheim.io 60.1699 24.9384
276 relay.qstr.app 50.1109 8.68213
277 ynostr.yael.at 60.1699 24.9384
nostr.stakey.net 52.3676 4.90414
espelho.girino.org 43.6532 -79.3832
nostr.snowbla.de:443 50.4754 12.3683
hasky.chat 40.7862 -74.0743
relay.plebeian.market 50.1109 8.68213
278 relay.zone667.com 60.1699 24.9384
cs-relay.nostrdev.com:443 50.4754 12.3683
relay.staging.plebeian.market:443 51.5072 -0.127586
relay.openresist.com 43.6532 -79.3832
nostr.spaceshell.xyz 43.6532 -79.3832
vault.iris.to:443 43.6532 -79.3832
nostr.easycryptosend.it 43.6532 -79.3832
nostr.bitcoiner.social:443 47.6743 -117.112
relay.fiatdenier.com 43.7787 -79.5393
relay.gulugulu.moe:443 43.6532 -79.3832
relay.mitosystem.net -23.5558 -46.6396
relay.mostro.network 40.8302 -74.1299
relay1.nostrchat.io 60.1699 24.9384
nostr.tabordalab.com 60.1699 24.9384
relay.gulugulu.moe 43.6532 -79.3832
pocketrelay.live 40.8302 -74.1299
relay.nostrfeed.com 60.1699 24.9384
testr.nymble.world 40.8054 -74.0241
x.kojira.io 43.6532 -79.3832
relay.sigit.io 50.4754 12.3683
nostrcity-club.fly.dev:443 38.7946 -106.535
relay.wisp.talk:443 49.4543 11.0746
relay-testnet.k8s.layer3.news 37.3387 -121.885
nostr-dev.wellorder.net 45.5201 -122.99
nostr.pbfs.io:443 50.4754 12.3683
nostr.takasaki.dev 43.6532 -79.3832
relay.snort.social 53.3498 -6.26031
articles.layer3.news 37.3387 -121.885
staging.yabu.me 35.6092 139.73
nostr.rtvslawenia.com:443 49.4543 11.0746
nostr-relay.xbytez.io:443 50.6924 3.20113
public.crostr.com:443 43.6532 -79.3832
nostr.ltd 19.076 72.8777
nostr-02.uid.ovh 50.9871 2.12554
nostr.islandarea.net:443 35.4669 -97.6473
relay.kilombino.com 43.6532 -79.3832
relay.satlantis.io 39.0438 -77.4874
nostr.computingcache.com 45.5341 -122.956
nostr.wecsats.io 43.6532 -79.3832
nostr-relay.zimage.com 34.282 -118.439
nostr.tbxnetworx.de 47.8226 10.0064
relay-can.zombi.cloudrodion.com 43.6532 -79.3832
relay.klabo.world 47.674 -122.122
auth.nostr1.com 40.7057 -74.0136
relay.degmods.com 50.4754 12.3683
relay.olas.app 60.1699 24.9384
fanfares.nostr1.com 40.7057 -74.0136
node.kommonzenze.de 49.4521 11.0767
relay.bitos.space 43.6532 -79.3832
relay01.lnfi.network 35.6764 139.65
relay.froth.zone 60.1699 24.9384
denimroad.feeds.relay.tools 38.6327 -90.1961
nostr.myshosholoza.co.za 52.3676 4.90414
21milionidinostr.duckdns.org 41.8945 12.6493
relay.getvia.xyz 60.1699 24.9384
ribo.us.nostria.app 43.6532 -79.3832
relay.nostrhub.fr 48.1045 11.6004
talon.quest 43.6532 -79.3832
portal-relay.pareto.space 49.4521 11.0767
relayone.geektank.ai 39.0997 -94.5786
relay.orangesync.tech 40.7128 -74.006
strfry.apps3.slidestr.net 40.4167 -3.70329
relay.mleku.dev 32.7767 -96.797
relay.nostrian-conquest.com 41.223 -111.974
279 relay.underorion.se 50.1109 8.68213
280 vault.iris.to relay.beamhop.com 43.6532 -79.3832
281 schnorr.me relay.paulstephenborile.com 43.6532 49.4543 -79.3832 11.0746
282 relay.bullishbounty.com:443 nostr.easycryptosend.it 43.6532 -79.3832
283 relay.i9.eti.br nostr-verified.wellorder.net 43.6532 45.5201 -79.3832 -122.99
nostr-pub.wellorder.net 45.5201 -122.99
relay.cyberguy.fyi 52.6907 4.8181
relay.mappingbitcoin.com 43.6532 -79.3832
nostr-relay.acloud.kdns.fr 43.6532 -79.3832
nostr.2b9t.xyz 34.0549 -118.243
nostr.2b9t.xyz:443 34.0549 -118.243
dev-relay.nostreon.com 60.1699 24.9384
nostr.purpura.cloud 43.6532 -79.3832
nos.lol:443 50.4754 12.3683
nostr-relay.corb.net 39.6478 -104.988
relay.scuba323.com 40.8218 -74.45
relay.agora.social 50.7383 15.0648
nostr.data.haus 50.4754 12.3683
relay.satsapp.me 50.4754 12.3683
nostr-rs-relay-ishosta.phamthanh.me 43.6532 -79.3832
284 nostr.hekster.org:443 37.3986 -121.964
285 relay.btcforplebs.com relay.samt.st 43.6532 40.8302 -79.3832 -74.1299
286 relay-arg.zombi.cloudrodion.com nostr.islandarea.net:443 1.35208 35.4669 103.82 -97.6473
287 nostr.wild-vibes.ts.net nostr-01.uid.ovh 48.8566 50.9871 2.35222 2.12554
relay.bornheimer.app 50.1109 8.68213
relay02.lnfi.network 35.6764 139.65
relay.kaleidoswap.com 50.8476 4.35717
strfry.shock.network 39.0438 -77.4874
nostr.txlyre.website 60.1699 24.9384
nostr.twinkle.lol 51.902 7.6657
relay.satmaxt.xyz 43.6532 -79.3832
relay.vrtmrz.net:443 43.6532 -79.3832
aurum.saturnali.net 46.2044 6.14316
nostrrelay.taylorperron.com 45.5029 -73.5723
nostrelay.circum.space 52.2245 8.826
relay.mostr.pub:443 43.6532 -79.3832
nostr.data.haus:443 50.4754 12.3683
relay.nostreon.com 60.1699 24.9384
relay.plebeian.market:443 50.1109 8.68213
relay.damus.io 43.6532 -79.3832
budabit.nostr1.com 40.7057 -74.0136
bucket.coracle.social 37.7775 -122.397
relay.chorus.community:443 50.7529 6.84585
relay.hivetalk.org 40.8302 -74.1299
relay.favillakey.io -27.4705 153.026
nostr.mom 50.4754 12.3683
relay2.orangesync.tech 40.7128 -74.006
relay.artiostr.ch 43.6532 -79.3832
relay.lacrypta.ar 43.6532 -79.3832
treuzkas.branruz.com 48.8575 2.35138
relay2.veganostr.com 60.1699 24.9384
borgar.lol 52.3676 4.90414
relay.ltgnet.work:8443 51.0511 -114.075
relay.nostrmap.net 60.1699 24.9384
nostr.mom:443 50.4754 12.3683
relay.decentralia.fr 48.122 11.589
relay.atsocy.com 43.6532 -79.3832
strfry.shock.network:443 39.0438 -77.4874
relay.vrtmrz.net 43.6532 -79.3832
0x-nostr-relay.fly.dev 38.7946 -106.535
relay.veganostr.com 60.1699 24.9384
relay.nostr.bond 40.1885 29.061
relay.angor.io 48.1046 11.6002
nostr.sathoarder.com:443 48.5734 7.75211
nostr.oxtr.dev 50.4754 12.3683
dev.relay.stream 43.6532 -79.3832
relay.mitchelltribe.com 39.0438 -77.4874
nostr-relay.cbrx.io 43.6532 -79.3832
nostr2.girino.org:443 43.6532 -79.3832
relay.snotr.nl:49999 51.9758 4.31389
n.musicroadmap.com 35.694 139.754
relay.loveisbitcoin.com 43.6532 -79.3832
relay.conduit.market 38.7946 -106.535
schnorr.me:443 43.6532 -79.3832
nostr-kyomu-haskell.onrender.com 37.7775 -122.397
nostr.dlcdevkit.com 40.0992 -83.1141
relay2.angor.io 48.1046 11.6002
nostr.hifish.org 47.4009 8.57814
rele.speyhard.fi 51.5072 -0.127586
nostr.chaima.info:443 51.5072 -0.127586
relay.trotters.cc:443 43.6532 -79.3832
nostrmxn.lulus.com.mx 59.4016 17.9455
chat-relay.zap-work.com:443 43.6532 -79.3832
relay.tdw.gg 34.0549 -118.243
relay.ohstr.com 43.6532 -79.3832
relay.guggero.org 46.5971 9.59652
nostr.stakey.net:443 52.3676 4.90414
dev-relay.nostr.space 43.6532 -79.3832
nostr.snowbla.de 50.4754 12.3683
relay.piazza.today 48.122 11.589
relay.fountain.fm:443 43.6532 -79.3832
288 relay-dev.gulugulu.moe 43.6532 -79.3832
289 relay.ditto.pub:443 relay.44billion.net 43.6532 -79.3832
290 vm-1734.lnvps.cloud dev-relay.nostreon.com 53.3498 60.1699 -6.26031 24.9384
291 relay.beginningend.com nostr.pbfs.io 35.2227 50.4754 -97.4786 12.3683
292 nos.lol nrs-01.darkcloudarcade.com 50.4754 39.0997 12.3683 -94.5786
293 nostr-01.yakihonne.com:443 relay01.lnfi.network 1.32123 35.6764 103.695 139.65
294 relay.sigit.io:443 relay.mostr.pub 50.4754 43.6532 12.3683 -79.3832
295 nostr.janx.com strfry.apps3.slidestr.net 43.6532 40.4167 -79.3832 -3.70329
296 relay.wavlake.com nostr.linky.fit 41.2619 50.1109 -95.8608 8.68213
relayrs.notoshi.win:443 43.6532 -79.3832
openrelay.ziomc.com 45.4642 9.18998
nostr.88mph.life 52.1941 -2.21905
maxq.descendant.io 43.6532 -79.3832
nostr.wecsats.io:443 43.6532 -79.3832
relay.bitmacro.cloud 43.6532 -79.3832
relay.lightning.pub 39.0438 -77.4874
strfry.bonsai.com 39.0438 -77.4874
chat-relay.zap-work.com 43.6532 -79.3832
relay.illuminodes.com 43.6532 -79.3832
nostr.thebiglake.org 32.71 -96.6745
ribo.eu.nostria.app 43.6532 -79.3832
relay.novospes.com 43.6532 -79.3832
nostr.notribe.net 40.8302 -74.1299
nostr-relay.corb.net:443 39.6478 -104.988
relay.pprgb.app 52.52 13.405
relay.wellorder.net 45.5201 -122.99
relay.mitchelltribe.com:443 39.0438 -77.4874
cyberspace.nostr1.com 40.7057 -74.0136
bruh.samt.st 43.6532 -79.3832
297 nostr.davenov.com 50.1109 8.68213
298 nostr.sovereignservices.xyz relay.noeudlibre.fr 43.6532 50.6924 -79.3832 3.20113
299 relay-dev.gulugulu.moe:443 hasky.chat 43.6532 40.7862 -79.3832 -74.0743
300 nostr.unkn0wn.world relay.artio.inf.unibe.ch 46.8499 46.9501 9.53287 7.43678
relay.nostr.space 43.6532 -79.3832
infinity-signal-relay.digitalforlifeagency.workers.dev 43.6532 -79.3832
relay.ezernode.net 52.6921 6.19372
no.str.cr 8.96171 -83.5246
spamspamspamspam.rest 43.6532 -79.3832
relay.ru.ac.th 13.7607 100.627
relay.tdw.lol 41.8781 -87.6298
buzz.cashu.space 50.1109 8.68213
relay.duck1123.com 43.6532 -79.3832
relay.tapestry.ninja 40.8054 -74.0241
relay.layer.systems 49.0291 8.35695
relay.dyne.org 49.0291 8.35705
relay.aidatanorge.no 43.6532 -79.3832
relay.opmaat.org 60.1699 24.9384
nostr.azzamo.net 52.2633 21.0283
relay.mccormick.cx 52.3563 4.95714
relay.nostr.com 50.1109 8.68213
nostr.plantroon.com 50.1013 8.62643
support.flotilla.social 32.9483 -96.7299
relay.veganostr.com:443 60.1699 24.9384
relay.notoshi.win 12.9333 100.883
relay.edufeed.org 49.4521 11.0767
memlay.v0l.io 53.3498 -6.26031
bitcoiner.social 47.6743 -117.112
301 relay.manneken.brussels 49.4543 11.0746
302 nosflare-leefcore.leefcore.workers.dev relay.veganostr.com 43.6532 60.1699 -79.3832 24.9384
303 no.str.cr:443 relay.nexterz.com 8.96171 43.6532 -83.5246 -79.3832
304 nostr.snowbla.de 50.4754 12.3683
305 nostr.unkn0wn.world 46.8499 9.53287
306 nostr.wild-vibes.ts.net 48.8566 2.35222
307 relay.bitos.space 43.6532 -79.3832
308 relay-na1.metanomalist.com 43.6532 -79.3832
309 relay.favillakey.io -27.4705 153.026
310 relay.nearhood.co.uk 51.5134 -0.0890675
311 relay.illuminodes.com 43.6532 -79.3832
312 nostr.dlcdevkit.com 40.0992 -83.1141
313 relay.nostr.net 43.6532 -79.3832
314 relay.trotters.cc:443 43.6532 -79.3832
315 cyberspace.nostr1.com 40.7057 -74.0136
316 nostr-relay.nextblockvending.com 47.2343 -119.853
317 relay.btcforplebs.com 43.6532 -79.3832
318 nostr.azzamo.net:443 52.2633 21.0283
319 relay.tdw.gg 34.0549 -118.243
320 relay.veganostr.com:443 60.1699 24.9384
321 wisp.djorivaltech.com 43.6532 -79.3832
322 relay.cosmicbolt.net 37.3986 -121.964
323 nostr.debate.report 50.1109 8.68213
324 nostr.na.social 43.6532 -79.3832
325 relay.kaleidoswap.com 50.8476 4.35717
326 nostr.sathoarder.com:443 48.5734 7.75211
327 relay.getsafebox.app:443 43.6532 -79.3832
328 relay.aarpia.com 37.3986 -121.964
329 nostr.sathoarder.com relay.satsapp.me 48.5734 50.4754 7.75211 12.3683
330 relay.getsafebox.app relay.decentralia.fr 43.6532 48.122 -79.3832 11.589
331 nostr.chaima.info relay.beginningend.com 51.5072 35.2227 -0.127586 -97.4786
relay.ditto.pub 43.6532 -79.3832
nostr.4rs.nl 49.0291 8.35696
rilo.nostria.app 43.6532 -79.3832
nostr.christiansass.de 51.7883 6.13865
332 relay.paulstephenborile.com:443 49.4543 11.0746
333 relay.earthly.city relay.satmaxt.xyz 34.1749 43.6532 -118.54 -79.3832
334 inbox.scuba323.com nostr.oxtr.dev:443 40.8218 50.4754 -74.45 12.3683
335 nostr-01.yakihonne.com vault.iris.to 1.32123 43.6532 103.695 -79.3832
336 articles.layer3.news:443 relay.ditto.pub:443 37.3387 43.6532 -121.885 -79.3832
337 offchain.pub relay.atsocy.com 39.1585 43.6532 -94.5728 -79.3832
338 strfry.bonsai.com:443 relay.vrtmrz.net 39.0438 43.6532 -77.4874 -79.3832
relay.nostriot.com 41.5695 -83.9786
relay.bullishbounty.com 43.6532 -79.3832
dm-test-strfry-generic.samt.st 43.6532 -79.3832
relay.lanacoin-eternity.com:443 40.8302 -74.1299
dm-test-strfry-discovery.samt.st 43.6532 -79.3832
nostr.emanuelemiani.it 45.778 8.79414
reraw.pbla2fish.cc 43.6532 -79.3832
relay.s-w.art 43.6532 -79.3832
relay.nostrcheck.me 43.6532 -79.3832
relay.npubhaus.com 43.6532 -79.3832
nostr.vulpem.com 49.4543 11.0746
relay.wasabiwallet.io 43.6532 -79.3832
nostr.liberty.fans 36.8767 -89.5879
relay.paulstephenborile.com 49.4543 11.0746
relay.nostrmap.net:443 60.1699 24.9384
tribune-panel-growing-noon.trycloudflare.com 43.6532 -79.3832
relay-na1.metanomalist.com 43.6532 -79.3832
relay.agentry.com 42.8864 -78.8784
relay.samt.st 40.8302 -74.1299
relay.nostx.io 43.6532 -79.3832
relay.cosmicbolt.net:443 37.3986 -121.964
relayrs.notoshi.win 43.6532 -79.3832
adre.su 59.9311 30.3609
nostrcheck.me 43.6532 -79.3832
relay-us.zombi.cloudrodion.com 40.7862 -74.0743
nrs-01.darkcloudarcade.com 39.0997 -94.5786
relay.nmail.li 50.9871 2.12554
relay.wisp.talk 49.4543 11.0746
relay.mostr.pub 43.6532 -79.3832
relay.jmoose.rocks 32.8009 -96.8195
relay.laantungir.net -19.4692 -42.5315
nostr.nodesmap.com 59.3327 18.0656
freelay.sovbit.host 60.1699 24.9384
339 soloco.nl 43.6532 -79.3832
340 ribo.nostria.app nostr-rs-relay-ishosta.phamthanh.me 43.6532 -79.3832
341 relay.chatbett.de relayone.soundhsa.com:443 40.7128 39.0997 -74.006 -94.5786
342 groups.beginningend.com nostr.wawasoft.net 35.2227 43.6532 -97.4786 -79.3832
343 relay.chorus.community relay.solife.me 50.7529 43.6532 6.84585 -79.3832
344 wisp.djorivaltech.com nostr.mom 43.6532 50.4754 -79.3832 12.3683
345 nostr-02.yakihonne.com relay.getvia.xyz 1.32123 60.1699 103.695 24.9384
346 freelay.sovbit.host 60.1699 24.9384
347 relay-rpi.edufeed.org:443 49.4521 11.0767
348 relay.bullishbounty.com:443 43.6532 -79.3832
349 schnorr.me:443 43.6532 -79.3832
350 temp.iris.to 43.6532 -79.3832
351 nostr-relay.cbrx.io 43.6532 -79.3832
352 strfry.bonsai.com:443 39.0438 -77.4874
353 nostr.hoppe-relay.it.com 42.8864 -78.8784
354 dm-test-nostr-rs-42-disabled.samt.st 43.6532 -79.3832
355 relay.nostriches.club 43.6532 -79.3832
356 relay.dyne.org 49.0291 8.35705
357 yabu.me 35.6092 139.73
358 relay.chorus.community:443 48.5333 10.7
359 cs-relay.nostrdev.com:443 50.4754 12.3683
360 relay.mwaters.net 50.9871 2.12554
361 no.str.cr 8.96171 -83.5246
362 nostr.hifish.org 47.4009 8.57814
363 relay.directsponsor.net 42.8864 -78.8784
364 public.obelisk.ar 43.6532 -79.3832
365 nostr.carroarmato0.be 50.914 3.21378
366 nostr.sovereignservices.xyz 43.6532 -79.3832
367 nostr.pbfs.io:443 50.4754 12.3683
368 relay.degmods.com 50.4754 12.3683
369 relay.plebeian.market 50.1109 8.68213
370 offchain.pub 39.1585 -94.5728
371 nostr.thebiglake.org 32.71 -96.6745
372 bendernostur.duckdns.org:8443 50.1109 8.68213
373 nostr-relay.zimage.com 34.282 -118.439
374 nostr.ac 38.958 -77.3592
375 relay.nostr.com 50.1109 8.68213
376 bitcoiner.social 47.6743 -117.112
377 nostr.wecsats.io 43.6532 -79.3832
378 relay.pprgb.app 52.52 13.405
379 nostr.girino.org 43.6532 -79.3832
380 nostr.bitcoiner.social 47.6743 -117.112
381 nostr.planix.org 43.6532 -79.3832
382 nostr.data.haus:443 50.4754 12.3683
383 relay.nostrmap.net:443 60.1699 24.9384
384 relay.arx-ccn.com 50.4754 12.3683
385 relay.agorist.space:443 52.3734 4.89406
386 relay.laantungir.net -19.4692 -42.5315
387 relay.edufeed.org:443 49.4521 11.0767
388 nostrcity-club.fly.dev:443 38.7946 -106.535
389 nostr.nodesmap.com 59.3327 18.0656

View File

@ -3,7 +3,6 @@ package com.bitchat.android.mesh
import android.util.Log
import com.bitchat.android.crypto.EncryptionService
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.sync.PacketIdUtil
import com.bitchat.android.protocol.MessageType
import com.bitchat.android.model.RoutedPacket
import com.bitchat.android.noise.AuthenticatedNoiseSession
@ -239,9 +238,21 @@ class SecurityManager(private val encryptionService: EncryptionService, private
return encryptionService.getCombinedPublicKeyData()
}
/** Deduplicates by peer and a hash of packet type, sender, timestamp, and full payload. */
/**
* Generate message ID for duplicate detection
*/
private fun generateMessageID(packet: BitchatPacket, peerID: String): String {
return "$peerID-${PacketIdUtil.computeIdHex(packet)}"
return when (MessageType.fromValue(packet.type)) {
MessageType.FRAGMENT -> {
// For fragments, include the payload hash to distinguish different fragments
"${packet.timestamp}-$peerID-${packet.type}-${packet.payload.contentHashCode()}"
}
else -> {
// For other messages, use a truncated payload hash
val payloadHash = packet.payload.sliceArray(0 until minOf(64, packet.payload.size)).contentHashCode()
"${packet.timestamp}-$peerID-$payloadHash"
}
}
}
/**

View File

@ -21,9 +21,7 @@ import java.math.BigInteger
* Includes secp256k1 operations, ECDH, and NIP-44 encryption
*/
object NostrCrypto {
internal const val NIP17_DEFAULT_MAX_PAST_SECONDS = 79_200
private val secureRandom = SecureRandom()
// NIP-44 v2 only
@ -319,9 +317,9 @@ object NostrCrypto {
}
/**
* Random timestamp in the past, defaulting to 22 hours to leave 2 hours of slack inside iOS's 24-hour lookback.
* Random timestamp up to maxPastSeconds in the past (default 2 days)
*/
fun randomizeTimestampUpToPast(maxPastSeconds: Int = NIP17_DEFAULT_MAX_PAST_SECONDS): Int {
fun randomizeTimestampUpToPast(maxPastSeconds: Int = 172800): Int {
val now = (System.currentTimeMillis() / 1000).toInt()
val offset = if (maxPastSeconds > 0) secureRandom.nextInt(maxPastSeconds + 1) else 0
return now - offset

View File

@ -79,9 +79,9 @@ class NostrEventDeduplicator(
* @return true if the event is a duplicate (already seen), false if it's new
*/
fun isDuplicate(eventId: String): Boolean {
totalChecks++
synchronized(lruLock) {
totalChecks++
val existingNode = nodeMap[eventId]
if (existingNode != null) {

View File

@ -37,7 +37,7 @@ object NostrProtocol {
val rumorId = rumorBase.computeEventIdHex()
val rumor = rumorBase.copy(id = rumorId)
// 2. Seal the rumor with 2 hours of slack inside iOS's 24-hour lookback.
// 2. Seal the rumor (kind 13) signed by sender, timestamp randomized up to 2 days
val sealedEvent = createSeal(
rumor = rumor,
recipientPubkey = recipientPubkey,

View File

@ -14,6 +14,8 @@ import okhttp3.*
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.math.min
import kotlin.math.pow
/**
* Manages WebSocket connections to Nostr relays
@ -46,8 +48,12 @@ class NostrRelayManager private constructor() {
"wss://nostr21.com"
)
// Reconnect backoff lives in RelayReconnectPolicy.
// Exponential backoff configuration (same as iOS)
private const val INITIAL_BACKOFF_INTERVAL = com.bitchat.android.util.AppConstants.Nostr.INITIAL_BACKOFF_INTERVAL_MS // 1 second
private const val MAX_BACKOFF_INTERVAL = com.bitchat.android.util.AppConstants.Nostr.MAX_BACKOFF_INTERVAL_MS // 5 minutes
private const val BACKOFF_MULTIPLIER = com.bitchat.android.util.AppConstants.Nostr.BACKOFF_MULTIPLIER
private const val MAX_RECONNECT_ATTEMPTS = com.bitchat.android.util.AppConstants.Nostr.MAX_RECONNECT_ATTEMPTS
// Track gift-wraps we initiated for logging
private val pendingGiftWrapIDs = ConcurrentHashMap.newKeySet<String>()
@ -1012,15 +1018,36 @@ class NostrRelayManager private constructor() {
if (!desiredConnected.get() ||
!isNetworkActionAllowed(connectionToken)
) return
// Every failure backs off and retries, including a name-resolution
// failure: "unable to resolve host" is what this device reports when it
// simply has no network, so treating it as permanent turns a walk
// through a tunnel into a dead relay layer for the rest of the process.
// Check if this is a DNS error
val errorMessage = error.message?.lowercase() ?: ""
if (errorMessage.contains("hostname could not be found") ||
errorMessage.contains("dns") ||
errorMessage.contains("unable to resolve host")) {
val relay = relaysList.find { it.url == relayUrl }
if (relay?.lastError == null) {
Log.w(TAG, "Nostr relay DNS failure; not retrying")
}
return
}
// Implement exponential backoff for non-DNS errors
val relay = relaysList.find { it.url == relayUrl } ?: return
relay.reconnectAttempts = RelayReconnectPolicy.nextAttempt(relay.reconnectAttempts)
val backoffInterval = RelayReconnectPolicy.backoffMs(relay.reconnectAttempts)
relay.reconnectAttempts++
// Stop attempting after max attempts
if (relay.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
Log.w(TAG, "Max Nostr relay reconnection attempts reached")
return
}
// Calculate backoff interval
val backoffInterval = min(
INITIAL_BACKOFF_INTERVAL * BACKOFF_MULTIPLIER.pow(relay.reconnectAttempts - 1.0),
MAX_BACKOFF_INTERVAL.toDouble()
).toLong()
relay.nextReconnectTime = System.currentTimeMillis() + backoffInterval
Log.d(TAG, "Scheduling Nostr relay reconnection")

View File

@ -1,45 +0,0 @@
package com.bitchat.android.nostr
import com.bitchat.android.util.AppConstants
import kotlin.math.min
import kotlin.math.pow
/**
* Reconnect schedule for a Nostr relay socket.
*
* A phone loses its data connection constantly — airplane mode, a tunnel, a
* Wi-Fi to cellular handover, a dead zone — and every relay fails at once when
* it does. The schedule therefore has to survive an outage of arbitrary length
* and heal on its own, because nothing else will: the relay layer registers no
* connectivity callback, the periodic subscription validator only repairs
* subscriptions on sockets that are already open, and `connect()` runs once at
* startup.
*
* So the backoff grows exponentially and then *saturates* rather than
* terminating. Retrying forever at the ceiling costs one connection attempt per
* relay per [AppConstants.Nostr.MAX_BACKOFF_INTERVAL_MS]; giving up costs the
* user every internet DM, delivery receipt and geohash channel until they
* notice and restart the app.
*/
internal object RelayReconnectPolicy {
/**
* Attempt count past which the interval stops growing. Beyond this the
* exponential term is already above the ceiling, so pinning it keeps the
* schedule at a steady [AppConstants.Nostr.MAX_BACKOFF_INTERVAL_MS] and
* keeps the exponent from running away over a long outage.
*/
const val SATURATION_ATTEMPTS: Int = AppConstants.Nostr.MAX_RECONNECT_ATTEMPTS
/** Attempt number to record after a failure at [previousAttempts]. */
fun nextAttempt(previousAttempts: Int): Int =
(previousAttempts.coerceAtLeast(0) + 1).coerceAtMost(SATURATION_ATTEMPTS)
/** Delay before the reconnect for a given attempt number (1-based). */
fun backoffMs(attempt: Int): Long {
val step = attempt.coerceAtLeast(1)
val exponential = AppConstants.Nostr.INITIAL_BACKOFF_INTERVAL_MS *
AppConstants.Nostr.BACKOFF_MULTIPLIER.pow(step - 1.0)
return min(exponential, AppConstants.Nostr.MAX_BACKOFF_INTERVAL_MS.toDouble()).toLong()
}
}

View File

@ -598,65 +598,4 @@ class SecurityManagerTest {
private fun String.hexToBytes(): ByteArray =
chunked(2).map { it.toInt(16).toByte() }.toByteArray()
// Duplicate-detection identity.
/**
* Two distinct packets that agree on their first 64 bytes and share a
* timestamp. The old key hashed only that prefix, with a 32-bit
* `contentHashCode`, so these were "the same packet" and the second was
* silently dropped.
*/
private fun prefixSharingPair(): Pair<BitchatPacket, BitchatPacket> {
val shared = ByteArray(64) { 0x7 }
val first = BitchatPacket(
version = 1u,
type = MessageType.NOISE_ENCRYPTED.value,
senderID = otherPeerID.hexToByteArrayForTest(),
recipientID = myPeerID.hexToByteArrayForTest(),
timestamp = 1_700_000_000_000uL,
payload = shared + byteArrayOf(0x01, 0x02, 0x03),
ttl = 7u
)
val second = first.copy(payload = shared + byteArrayOf(0x0A, 0x0B, 0x0C))
return first to second
}
@Test
fun `packets differing only past the first 64 bytes are not treated as duplicates`() {
val (first, second) = prefixSharingPair()
assertTrue(securityManager.validatePacket(first, otherPeerID))
assertTrue(
"A distinct packet must not be dropped as a duplicate",
securityManager.validatePacket(second, otherPeerID)
)
}
@Test
fun `a genuine replay of the same packet is still rejected`() {
// The other half: strengthening the identity must not weaken replay
// protection, which is the reason this cache exists.
val (first, _) = prefixSharingPair()
assertTrue(securityManager.validatePacket(first, otherPeerID))
assertFalse(
"The identical packet must still be caught",
securityManager.validatePacket(first, otherPeerID)
)
}
@Test
fun `the same packet from two different peers is tracked separately`() {
// Peer scoping is deliberately kept: PacketIdUtil covers the packet's
// own senderID, which is not the peer it was received from once a
// packet has been relayed.
val (first, _) = prefixSharingPair()
assertTrue(securityManager.validatePacket(first, otherPeerID))
assertTrue(securityManager.validatePacket(first, unknownPeerID))
}
private fun String.hexToByteArrayForTest(): ByteArray =
chunked(2).map { it.toInt(16).toByte() }.toByteArray()
}

View File

@ -1,66 +0,0 @@
package com.bitchat.android.nostr
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import java.util.concurrent.CountDownLatch
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
class NostrEventDeduplicatorTest {
@Test
fun `isDuplicate flags a repeated event id`() {
val deduplicator = NostrEventDeduplicator(maxCapacity = 10)
assertEquals(false, deduplicator.isDuplicate("event-1"))
assertEquals(true, deduplicator.isDuplicate("event-1"))
}
@Test
fun `capacity evicts the least recently used event id`() {
val deduplicator = NostrEventDeduplicator(maxCapacity = 2)
deduplicator.isDuplicate("a")
deduplicator.isDuplicate("b")
deduplicator.isDuplicate("c") // evicts "a"
assertTrue(deduplicator.contains("b"))
assertTrue(deduplicator.contains("c"))
assertEquals(false, deduplicator.contains("a"))
}
/**
* totalChecks used to be incremented outside the lruLock that guards every other
* mutation in this class, so concurrent callers could race on the read-modify-write
* and lose increments. Every other counter (duplicateCount, evictionCount) was
* already incremented under the lock, which is why only this one drifted.
*/
@Test
fun `totalChecks counts every call exactly once under concurrent access`() {
val deduplicator = NostrEventDeduplicator(maxCapacity = 10_000)
val threadCount = 8
val checksPerThread = 2_000
val executor = Executors.newFixedThreadPool(threadCount)
val start = CountDownLatch(1)
val done = CountDownLatch(threadCount)
repeat(threadCount) { threadIndex ->
executor.submit {
start.await()
repeat(checksPerThread) { callIndex ->
deduplicator.isDuplicate("thread-$threadIndex-event-$callIndex")
}
done.countDown()
}
}
start.countDown()
assertTrue(done.await(10, TimeUnit.SECONDS))
executor.shutdown()
assertEquals(
(threadCount * checksPerThread).toLong(),
deduplicator.getStats().totalChecks
)
}
}

View File

@ -3,7 +3,6 @@ package com.bitchat.android.nostr
import com.google.gson.Gson
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class NostrProtocolTest {
@ -42,52 +41,6 @@ class NostrProtocolTest {
assertNull(decrypted)
}
@Test
fun createPrivateMessage_reservesSlackInsideIosLookback() {
val sender = NostrIdentity.generate()
val recipient = NostrIdentity.generate()
assertEquals(
IOS_DM_LOOKBACK_SECONDS - TIMESTAMP_SAFETY_SLACK_SECONDS,
NostrCrypto.NIP17_DEFAULT_MAX_PAST_SECONDS
)
repeat(20) {
val beforeCreation = (System.currentTimeMillis() / 1000).toInt()
val giftWrap = NostrProtocol.createPrivateMessage(
content = "bitchat1:test",
recipientPubkey = recipient.publicKeyHex,
senderIdentity = sender
).single()
val afterCreation = (System.currentTimeMillis() / 1000).toInt()
val sealJson = NostrCrypto.decryptNIP44(
ciphertext = giftWrap.content,
senderPublicKeyHex = giftWrap.pubkey,
recipientPrivateKeyHex = recipient.privateKeyHex
)
val seal = gson.fromJson(sealJson, NostrEvent::class.java)
assertTimestampWithinIosLookback("gift wrap", giftWrap.createdAt, beforeCreation, afterCreation)
assertTimestampWithinIosLookback("seal", seal.createdAt, beforeCreation, afterCreation)
}
}
private fun assertTimestampWithinIosLookback(
envelope: String,
createdAt: Int,
beforeCreation: Int,
afterCreation: Int
) {
assertTrue(
"$envelope timestamp must leave 2 hours inside the iOS lookback",
createdAt >= beforeCreation - MAX_OUTBOUND_BACKDATE_SECONDS
)
assertTrue(
"$envelope timestamp must not be in the future",
createdAt <= afterCreation
)
}
private fun forgedGiftWrap(
content: String,
claimedSender: NostrIdentity,
@ -129,11 +82,4 @@ class NostrProtocolTest {
content = giftWrapContent
).sign(wrapPrivateKey)
}
private companion object {
const val IOS_DM_LOOKBACK_SECONDS = 86_400
const val TIMESTAMP_SAFETY_SLACK_SECONDS = 7_200
const val MAX_OUTBOUND_BACKDATE_SECONDS =
IOS_DM_LOOKBACK_SECONDS - TIMESTAMP_SAFETY_SLACK_SECONDS
}
}

View File

@ -1,96 +0,0 @@
package com.bitchat.android.nostr
import com.bitchat.android.util.AppConstants
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The relay layer has no connectivity callback, its periodic validator only
* repairs subscriptions on sockets that are already open, and `connect()` runs
* once at startup. The backoff schedule is therefore the only thing that can
* bring relays back after the phone loses its data connection, so it has to
* saturate rather than terminate.
*/
class RelayReconnectPolicyTest {
private val initial = AppConstants.Nostr.INITIAL_BACKOFF_INTERVAL_MS
private val ceiling = AppConstants.Nostr.MAX_BACKOFF_INTERVAL_MS
@Test
fun `the first retry waits the initial interval`() {
assertEquals(initial, RelayReconnectPolicy.backoffMs(RelayReconnectPolicy.nextAttempt(0)))
}
@Test
fun `the interval doubles per attempt until it reaches the ceiling`() {
var attempt = 0
var previous = 0L
var sawCeiling = false
repeat(RelayReconnectPolicy.SATURATION_ATTEMPTS) {
attempt = RelayReconnectPolicy.nextAttempt(attempt)
val delay = RelayReconnectPolicy.backoffMs(attempt)
assertTrue("delay must never exceed the ceiling", delay <= ceiling)
if (delay == ceiling) {
sawCeiling = true
} else {
assertEquals("expected doubling below the ceiling", maxOf(initial, previous * 2), delay)
}
previous = delay
}
assertTrue("the schedule must actually reach the ceiling", sawCeiling)
}
@Test
fun `an outage longer than the schedule keeps retrying at the ceiling`() {
var attempt = 0
// Far past the old give-up point; a real outage can last hours.
repeat(500) { attempt = RelayReconnectPolicy.nextAttempt(attempt) }
assertEquals(RelayReconnectPolicy.SATURATION_ATTEMPTS, attempt)
assertEquals(ceiling, RelayReconnectPolicy.backoffMs(attempt))
}
@Test
fun `a long outage cannot run the attempt counter or the exponent away`() {
var attempt = 0
repeat(10_000) { attempt = RelayReconnectPolicy.nextAttempt(attempt) }
val delay = RelayReconnectPolicy.backoffMs(attempt)
assertTrue("delay must stay finite and bounded", delay in 1..ceiling)
}
@Test
fun `a successful connection resets the schedule to the initial interval`() {
var attempt = 0
repeat(6) { attempt = RelayReconnectPolicy.nextAttempt(attempt) }
assertTrue(RelayReconnectPolicy.backoffMs(attempt) > initial)
// updateRelayStatus zeroes reconnectAttempts on a successful open.
attempt = 0
assertEquals(initial, RelayReconnectPolicy.backoffMs(RelayReconnectPolicy.nextAttempt(attempt)))
}
@Test
fun `a nonsensical stored attempt count still yields a usable delay`() {
assertEquals(initial, RelayReconnectPolicy.backoffMs(RelayReconnectPolicy.nextAttempt(-5)))
assertTrue(RelayReconnectPolicy.backoffMs(0) in 1..ceiling)
assertTrue(RelayReconnectPolicy.backoffMs(Int.MAX_VALUE) in 1..ceiling)
}
@Test
fun `the whole schedule stays under an hour of total wait before the ceiling`() {
var attempt = 0
var total = 0L
repeat(RelayReconnectPolicy.SATURATION_ATTEMPTS) {
attempt = RelayReconnectPolicy.nextAttempt(attempt)
total += RelayReconnectPolicy.backoffMs(attempt)
}
assertTrue("reaching the steady state must not take an hour", total < 60 * 60 * 1000L)
}
}

View File

@ -17,7 +17,7 @@ The remaining implementation work and milestone progress are tracked in
| Inner payloads | Noise type bytes, private-message TLVs, peer-state TLVs, file-transfer TLVs, live-voice bursts, fragment header, sync request TLVs | `ClientRewriteWireContractTest`, `AuthenticatedPeerStateTest`, `PrivateMediaTransferPreparerTest`, `VoiceBurstPacketTest`, `FragmentManagerTest` |
| Identity/security | Announcement extensions, capability bitfield endianness, Noise static-key binding, handshake identity binding, signatures | `IdentityAnnouncementTest`, `NoiseSessionManagerIdentityBindingTest`, `ClientRewritePrimitiveContractTest` |
| Sync/routing | Stable packet IDs, GCS bitstream, replay collapse, TTL handling, relay choice, confirmed graph edges | `ClientRewritePrimitiveContractTest`, `GCSFilterTest`, `PacketRelayManagerTest`, `MeshGraphServiceTest`, `TransportBridgeServiceTest` |
| Nostr | Bech32, secp256k1 key derivation, NIP-01 event IDs/signatures, NIP-44 authenticated encryption, NIP-13 PoW, authenticated NIP-17 seals, 22h outbound envelope randomization | `ClientRewriteNostrContractTest`, `NostrProtocolTest` |
| Nostr | Bech32, secp256k1 key derivation, NIP-01 event IDs/signatures, NIP-44 authenticated encryption, NIP-13 PoW, authenticated NIP-17 seals | `ClientRewriteNostrContractTest`, `NostrProtocolTest` |
| Application state | Peer unions, canonical private conversations, chronological history, delivery/read behavior, media migration policy | `AppStateStoreTest`, `PrivateChatManagerTest`, `MediaSendingManagerMigrationTest` |
## Golden-vector policy
@ -31,11 +31,6 @@ Round-trip tests remain useful but are not sufficient on their own: an encoder
and decoder can share the same defect. Each critical wire format therefore has
at least one literal vector.
NIP-17 receivers should reserve safety slack beyond the maximum timestamp
randomization used by senders. Android caps outbound seal and gift-wrap
randomization at 22h, leaving 2 hours of slack inside iOS's 24-hour
subscription window, while retaining its 48-hour receive lookback.
## Rewrite acceptance gate
From a configured Android development environment, run:

View File

@ -19,8 +19,8 @@ android {
targetSdk = libs.versions.targetSdk.get().toInt()
// Wear releases use a separate high range because Play requires every artifact in
// one application ID to have a unique version code across all form factors.
versionCode = 1_000_000_004
versionName = "0.1.3"
versionCode = 1_000_000_003
versionName = "0.1.2"
vectorDrawables {
useSupportLibrary = true

View File

@ -18,6 +18,9 @@ import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@ -27,6 +30,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.runtime.saveable.listSaver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
@ -35,7 +39,7 @@ import androidx.core.content.ContextCompat
import androidx.wear.compose.material3.Button
import androidx.wear.compose.material3.MaterialTheme
import androidx.wear.compose.material3.Text
import androidx.wear.compose.material3.OutlinedButton
import androidx.wear.compose.material3.TextButton
import com.bitchat.watch.mesh.WearMeshService
import com.bitchat.watch.notification.WearNotificationCoordinator
import com.bitchat.watch.service.WearMeshForegroundService
@ -46,7 +50,6 @@ import com.bitchat.watch.ui.PeopleScreen
import com.bitchat.watch.ui.UserDetailScreen
import com.bitchat.watch.ui.VerificationCodeScreen
import com.bitchat.watch.ui.WearChatState
import com.bitchat.watch.ui.WearFormScreen
import com.bitchat.watch.ui.sendPrivateMessage
import com.bitchat.watch.ui.sendPublicMessage
import com.bitchat.watch.ui.theme.BitchatWearTheme
@ -457,110 +460,104 @@ internal fun WearNavHost(
@Composable
fun NotificationPermissionScreen(onResult: (Boolean) -> Unit, onSkip: () -> Unit) {
val launcher =
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
onResult(granted)
}
val launcher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted -> onResult(granted) }
WearFormScreen {
item {
Text(
text = "Message alerts",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
)
}
item {
Text(
text = "Alerts for encrypted direct messages",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 6.dp, bottom = 10.dp),
)
}
item {
Button(
onClick = {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
launcher.launch(Manifest.permission.POST_NOTIFICATIONS)
} else {
onResult(true)
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 20.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = "Message alerts",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
Text(
text = "Alerts for encrypted direct messages",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 6.dp, bottom = 10.dp)
)
Button(
onClick = {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
launcher.launch(Manifest.permission.POST_NOTIFICATIONS)
} else {
onResult(true)
}
) {
Text("Enable")
}
) {
Text("Enable")
}
item {
OutlinedButton(onClick = onSkip) {
Text("Not now")
}
TextButton(onClick = onSkip) {
Text("Not now")
}
}
}
@Composable
fun PermissionRequestScreen(onGranted: () -> Unit) {
val launcher =
rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) {
onGranted()
}
val launcher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { onGranted() }
WearFormScreen {
item {
Text(
text = "bitchat",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
)
}
item {
Text(
text = "Needs Bluetooth to mesh with nearby devices",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 6.dp, bottom = 12.dp),
)
}
item {
Button(
onClick = {
launcher.launch(MainActivity.requiredPermissions().toTypedArray())
}
) {
Text("Grant access", textAlign = TextAlign.Center)
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 20.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = "bitchat",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
Text(
text = "Needs Bluetooth to mesh with nearby devices",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 6.dp, bottom = 12.dp)
)
Button(onClick = {
launcher.launch(MainActivity.requiredPermissions().toTypedArray())
}) {
Text("Grant access")
}
}
}
@Composable
fun BluetoothEnableScreen(onEnabled: () -> Unit) {
val launcher =
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
onEnabled()
}
val launcher = rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { onEnabled() }
WearFormScreen {
item {
Text(
text = "Bluetooth is off",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center,
)
}
item {
Button(
onClick = { launcher.launch(Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE)) },
modifier = Modifier.padding(top = 10.dp),
) {
Text("Turn on")
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 20.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = "Bluetooth is off",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurface
)
Button(
onClick = { launcher.launch(Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE)) },
modifier = Modifier.padding(top = 10.dp)
) {
Text("Turn on")
}
}
}

View File

@ -13,7 +13,6 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
@ -37,7 +36,6 @@ import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.unit.toSize
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
@ -76,7 +74,7 @@ fun ChatActionBar(
) {
Box(
modifier = Modifier
.size(48.dp)
.size(38.dp)
.clip(CircleShape)
.background(palette.inputButton)
.clickable { onKeyboard() },
@ -91,7 +89,7 @@ fun ChatActionBar(
}
Box(
modifier = Modifier
.size(48.dp)
.size(38.dp)
.clip(CircleShape)
.background(
when {
@ -152,7 +150,7 @@ fun VoiceRecordOverlay(
hoveringCancel: Boolean,
proximity: Float,
magnetPull: Offset,
onCancelBounds: (androidx.compose.ui.geometry.Rect) -> Unit,
onCancelBounds: (androidx.compose.ui.geometry.Rect) -> Unit
) {
val palette = LocalBitchatPalette.current
// The cancel morph, choreographed for feel:
@ -161,127 +159,101 @@ fun VoiceRecordOverlay(
// - the button leans toward the approaching finger (magnetic pull), chasing it with a
// smooth spring so it lags and settles naturally
// - scale blooms with a soft bounce on activation — no rotation, no wobble
val cancelScale by
androidx.compose.animation.core.animateFloatAsState(
targetValue = if (hoveringCancel) 1.32f else 1f + 0.1f * proximity,
animationSpec =
androidx.compose.animation.core.spring(
dampingRatio = androidx.compose.animation.core.Spring.DampingRatioMediumBouncy,
stiffness = androidx.compose.animation.core.Spring.StiffnessMedium,
),
label = "cancelSnap",
)
val pull by
androidx.compose.animation.core.animateOffsetAsState(
targetValue = magnetPull,
animationSpec =
androidx.compose.animation.core.spring(
dampingRatio = androidx.compose.animation.core.Spring.DampingRatioMediumBouncy,
stiffness = androidx.compose.animation.core.Spring.StiffnessMedium,
),
label = "magnetPull",
)
val cancelColor =
androidx.compose.ui.graphics.lerp(
MaterialTheme.colorScheme.primary,
MaterialTheme.colorScheme.error,
if (hoveringCancel) 1f else proximity * 0.85f,
)
val cancelScale by androidx.compose.animation.core.animateFloatAsState(
targetValue = if (hoveringCancel) 1.32f else 1f + 0.1f * proximity,
animationSpec = androidx.compose.animation.core.spring(
dampingRatio = androidx.compose.animation.core.Spring.DampingRatioMediumBouncy,
stiffness = androidx.compose.animation.core.Spring.StiffnessMedium
),
label = "cancelSnap"
)
val pull by androidx.compose.animation.core.animateOffsetAsState(
targetValue = magnetPull,
animationSpec = androidx.compose.animation.core.spring(
dampingRatio = androidx.compose.animation.core.Spring.DampingRatioMediumBouncy,
stiffness = androidx.compose.animation.core.Spring.StiffnessMedium
),
label = "magnetPull"
)
val cancelColor = androidx.compose.ui.graphics.lerp(
MaterialTheme.colorScheme.primary,
MaterialTheme.colorScheme.error,
if (hoveringCancel) 1f else proximity * 0.85f
)
AnimatedVisibility(
visible = voice.recording,
enter = fadeIn(tween(BitchatMotion.EMPHASIZED_MS)),
exit = fadeOut(tween(BitchatMotion.EMPHASIZED_MS)),
exit = fadeOut(tween(BitchatMotion.EMPHASIZED_MS))
) {
BoxWithConstraints(
modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background),
contentAlignment = Alignment.Center,
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background.copy(alpha = 0.96f))
.padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
val side =
if (LocalConfiguration.current.isScreenRound) {
roundContentSide(maxWidth.value, maxHeight.value).dp
} else {
minOf(maxWidth, maxHeight) - 24.dp
}
Column(
modifier = Modifier.size(side),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
// Text gets its natural height first. The decorative waveform and cancel icon
// share the remaining room instead of pushing instructions off the display.
BoxWithConstraints(
Modifier.weight(1f).fillMaxWidth(),
contentAlignment = Alignment.Center,
) {
val iconSize = minOf(52.dp, maxHeight / 1.32f, maxWidth / 1.32f)
Box(
modifier =
Modifier.onGloballyPositioned { coords ->
onCancelBounds(
androidx.compose.ui.geometry.Rect(
coords.localToRoot(
androidx.compose.ui.geometry.Offset.Zero
),
coords.size.toSize(),
)
)
}
.size(iconSize)
.graphicsLayer {
translationX = pull.x
translationY = pull.y
scaleX = cancelScale
scaleY = cancelScale
}
.clip(CircleShape)
.background(cancelColor),
contentAlignment = Alignment.Center,
) {
androidx.compose.animation.Crossfade(
targetState = hoveringCancel,
animationSpec = tween(BitchatMotion.STANDARD_MS),
label = "cancelIcon",
) { cancel ->
Icon(
imageVector = if (cancel) Icons.Filled.Close else Icons.Filled.Mic,
contentDescription = if (cancel) "cancel recording" else null,
tint = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.size(iconSize / 2),
Box(
modifier = Modifier
.onGloballyPositioned { coords ->
onCancelBounds(
androidx.compose.ui.geometry.Rect(
coords.localToRoot(androidx.compose.ui.geometry.Offset.Zero),
coords.size.toSize()
)
}
)
}
.size(52.dp)
.graphicsLayer {
translationX = pull.x
translationY = pull.y
scaleX = cancelScale
scaleY = cancelScale
}
.clip(CircleShape)
.background(cancelColor),
contentAlignment = Alignment.Center
) {
androidx.compose.animation.Crossfade(
targetState = hoveringCancel,
animationSpec = tween(BitchatMotion.STANDARD_MS),
label = "cancelIcon"
) { cancel ->
Icon(
imageVector = if (cancel) Icons.Filled.Close else Icons.Filled.Mic,
contentDescription = if (cancel) "cancel recording" else null,
tint = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.size(26.dp)
)
}
WaveformBars(
samples = voice.liveSamples,
progress = 1f,
activeColor = MaterialTheme.colorScheme.primary,
inactiveColor = MaterialTheme.colorScheme.primary,
modifier = Modifier.fillMaxWidth().height(12.dp),
)
Text(
text =
(if (voice.isLive) "LIVE " else "") +
"%d:%02d"
.format(
voice.elapsedMs / 1000 / 60,
voice.elapsedMs / 1000 % 60,
) +
"/0:10",
style = ChatVisualTokens.SystemActionStyle,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.fillMaxWidth(),
)
Text(
text = if (hoveringCancel) "Release to cancel" else "Lift finger to send",
style = ChatVisualTokens.SystemActionStyle,
color =
if (hoveringCancel) MaterialTheme.colorScheme.error
else palette.textTertiary,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
}
WaveformBars(
samples = voice.liveSamples,
progress = 1f,
activeColor = MaterialTheme.colorScheme.primary,
inactiveColor = MaterialTheme.colorScheme.primary,
modifier = Modifier
.padding(top = 16.dp)
.fillMaxWidth()
.height(44.dp)
)
Text(
text = (if (voice.isLive) "LIVE · " else "") + "%d:%02d".format(
voice.elapsedMs / 1000 / 60,
voice.elapsedMs / 1000 % 60
) + " / 0:10",
style = ChatVisualTokens.SenderStyle,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(top = 10.dp)
)
Text(
text = if (hoveringCancel) "Release to cancel" else "Lift finger to send",
style = ChatVisualTokens.SystemActionStyle,
color = if (hoveringCancel) MaterialTheme.colorScheme.error
else palette.textTertiary,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 2.dp)
)
}
}
}

View File

@ -28,19 +28,14 @@ import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.CompositingStrategy
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.changedToUp
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.wear.compose.foundation.lazy.TransformingLazyColumn
import androidx.wear.compose.foundation.lazy.TransformingLazyColumnState
import androidx.wear.compose.foundation.lazy.items
@ -55,12 +50,13 @@ import com.bitchat.watch.ui.theme.ChatVisualTokens
import com.bitchat.watch.ui.theme.LocalBitchatPalette
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlin.math.sign
/**
* The shared chat body for global chat and DM threads, following the classic messenger
* pattern: a TransformingLazyColumn message list (native Wear center-scaling/fade, rotary,
* scrollbar) with the header and action bar as floating overlays that get out of the way
* while scrolling up into history and return on a deliberate reverse scroll; at the newest message
* while scrolling up into history and return on any downward scroll; at the newest message
* they are always visible.
*
* The list's contentPadding is CONSTANT and both overlays are layout-neutral, so showing or
@ -96,38 +92,33 @@ fun ChatScaffold(
// Follow intent is changed only by an actual user scroll away from the newest item or by
// reaching the end again. A new item temporarily makes canScrollForward true before layout;
// treating that transient range change as user intent breaks automatic following.
var scrollIntent by remember { mutableStateOf(ChatScrollIntentState()) }
val density = LocalDensity.current
val scrollConnection = remember(columnState, density) {
object : NestedScrollConnection {
override fun onPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource
): Offset {
// Both Wear rotary and touch dispatch consumed movement here. Positive list
// movement is toward newer messages. Layout changes never enter this path.
scrollIntent = updatedChatScrollIntent(
current = scrollIntent,
deltaDp = -consumed.y / density.density,
isUserInput = source == NestedScrollSource.UserInput,
atNewest = !columnState.canScrollForward
)
return Offset.Zero
}
}
}
var followNewest by remember { mutableStateOf(true) }
val controlsVisible = remember { mutableStateOf(true) }
LaunchedEffect(columnState) {
var lastPosition = -1
var scrollIntent = ChatScrollIntentState()
snapshotFlow {
!columnState.canScrollForward && !columnState.isScrollInProgress
}.collect { atNewest ->
if (atNewest) scrollIntent = ChatScrollIntentState()
val first = columnState.layoutInfo.visibleItems.firstOrNull()
ChatScrollSnapshot(
canScrollForward = columnState.canScrollForward,
isScrollInProgress = columnState.isScrollInProgress,
position = (first?.index ?: 0) * 100_000 + (first?.offset ?: 0)
)
}.collect { snapshot ->
scrollIntent = updatedChatScrollIntent(
current = scrollIntent,
snapshot = snapshot,
previousPosition = lastPosition
)
followNewest = scrollIntent.followsNewest
controlsVisible.value = scrollIntent.controlsVisible
lastPosition = snapshot.position
}
}
// Stick to bottom when the user has not intentionally moved into history.
LaunchedEffect(columnState, messages.size) {
if (messages.isNotEmpty() && scrollIntent.followsNewest) {
if (messages.isNotEmpty() && followNewest) {
val expectedSingleMessageKey = messages.singleOrNull()?.id
scrollToNewestAfterItemsMeasured(
expectedItemCount = messages.size,
@ -146,14 +137,7 @@ fun ChatScaffold(
) {
// scrollBy to the end of the range: animateScrollToItem stops as soon as the
// item is partially visible, which left the last message cropped.
// Do not seize the list from an active drag/crown gesture, or follow an
// append whose measurement completed after the user entered history.
followNewestWhenIdle(
scrolling = snapshotFlow { columnState.isScrollInProgress },
shouldFollow = { scrollIntent.followsNewest }
) {
columnState.scroll { scrollBy(Float.MAX_VALUE) }
}
columnState.scroll { scrollBy(Float.MAX_VALUE) }
}
}
}
@ -165,10 +149,10 @@ fun ChatScaffold(
voice = voice,
onOpenImage = onOpenImage,
columnState = columnState,
controlsVisible = scrollIntent.controlsVisible,
controlsVisible = controlsVisible.value,
header = header,
actionBar = actionBar,
modifier = Modifier.fillMaxSize().nestedScroll(scrollConnection)
modifier = Modifier.fillMaxSize()
)
}
@ -177,30 +161,47 @@ internal data class MeasuredChatLayout(
val singleVisibleItemKey: Any?
)
internal data class ChatScrollSnapshot(
val canScrollForward: Boolean,
val isScrollInProgress: Boolean,
val position: Int
)
internal data class ChatScrollIntentState(
val followsNewest: Boolean = true,
val controlsVisible: Boolean = true,
val reversalDp: Float = 0f
val accumulatedDeltaPx: Int = 0
)
internal fun updatedChatScrollIntent(
current: ChatScrollIntentState,
deltaDp: Float,
isUserInput: Boolean,
atNewest: Boolean
snapshot: ChatScrollSnapshot,
previousPosition: Int
): ChatScrollIntentState {
if (atNewest) return ChatScrollIntentState()
if (!isUserInput || !deltaDp.isFinite() || deltaDp == 0f) return current
// Hysteresis measures net travel opposite the current controls state, not the sum of
// tiny back-and-forth movements. Keep it across discrete crown ticks and idle periods.
val reversal = (current.reversalDp + if (current.controlsVisible) -deltaDp else deltaDp)
.coerceAtLeast(0f)
val threshold = if (current.controlsVisible) 12f else 24f
val toggle = reversal >= threshold
if (!snapshot.canScrollForward) return ChatScrollIntentState()
if (!snapshot.isScrollInProgress || previousPosition < 0) return current
val delta = snapshot.position - previousPosition
val accumulatedDelta = when {
delta == 0 -> current.accumulatedDeltaPx
current.accumulatedDeltaPx == 0 ||
current.accumulatedDeltaPx.sign == delta.sign ->
current.accumulatedDeltaPx + delta
else -> delta
}
val movedAway = accumulatedDelta <= -CHAT_SCROLL_DIRECTION_THRESHOLD_PX
val movedTowardNewest = accumulatedDelta >= CHAT_SCROLL_DIRECTION_THRESHOLD_PX
return current.copy(
followsNewest = current.followsNewest && deltaDp >= 0f,
controlsVisible = if (toggle) !current.controlsVisible else current.controlsVisible,
reversalDp = if (toggle) 0f else reversal
followsNewest = current.followsNewest && !movedAway,
controlsVisible = when {
movedAway -> false
movedTowardNewest -> true
else -> current.controlsVisible
},
// Keep sub-threshold movement across discrete rotary events. Once intent is clear,
// start a fresh accumulator so reversing direction gets the same threshold treatment.
accumulatedDeltaPx = if (movedAway || movedTowardNewest) 0 else accumulatedDelta
)
}
@ -218,15 +219,6 @@ internal suspend fun scrollToNewestAfterItemsMeasured(
scrollToEnd()
}
internal suspend fun followNewestWhenIdle(
scrolling: Flow<Boolean>,
shouldFollow: () -> Boolean,
scrollToEnd: suspend () -> Unit
) {
scrolling.first { !it }
if (shouldFollow()) scrollToEnd()
}
@Composable
private fun ChatBody(
messages: List<BitchatMessage>,
@ -244,9 +236,6 @@ private fun ChatBody(
val context = LocalContext.current
val transformationSpec = rememberTransformationSpec()
val isScreenRound = LocalConfiguration.current.isScreenRound
val headerClearance = with(LocalDensity.current) {
maxOf(40.dp, 24.dp + (14f * 1.3f).sp.toDp() / 2 + 6.dp)
}
// Slide-to-cancel: while recording, the finger's position is tracked globally; the
// overlay's mic button reports its bounds and becomes the cancel target when the
// finger hovers it (with generous slack so the snap engages on approach).
@ -373,7 +362,7 @@ private fun ChatBody(
// duplicated padding and a shortened list viewport.
contentPadding = scaffoldPadding.withVerticalClearance(
layoutDirection = layoutDirection,
top = headerClearance,
top = CHAT_HEADER_CONTENT_CLEARANCE,
bottom = CHAT_ACTION_BAR_CLEARANCE
)
) {
@ -448,6 +437,8 @@ private fun ChatBody(
// Extra finger slack (px, ~28dp at watch density) around the cancel target so the snap
// engages as the finger approaches, not only on exact contact.
private const val CANCEL_HOVER_SLANT_PX = 56f
private const val CHAT_SCROLL_DIRECTION_THRESHOLD_PX = 24
private val CHAT_HEADER_CONTENT_CLEARANCE = 30.dp
private val CHAT_ACTION_BAR_CLEARANCE = 64.dp
private val CHAT_HEADER_EDGE_FADE = 36.dp
private val CHAT_ACTION_BAR_EDGE_FADE = 72.dp

View File

@ -1,7 +1,9 @@
package com.bitchat.watch.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusable
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
@ -116,79 +118,77 @@ private fun ChatHeader(
peerCount: Int,
unreadDms: Int,
expanded: Boolean,
onOpenPeople: () -> Unit,
onOpenPeople: () -> Unit
) {
// Floating title row: full-size at the newest messages, shrinks to its dense form
// while scrolling up into history. Rendered as an overlay, so the animation only
// relayouts this row, never the message list.
val spec =
androidx.compose.animation.core.tween<androidx.compose.ui.unit.Dp>(
BitchatMotion.STANDARD_MS
)
val iconSize by
androidx.compose.animation.core.animateDpAsState(
targetValue = if (expanded) 14.dp else 12.dp,
animationSpec = spec,
label = "hdrIcon",
)
val titleSize by
androidx.compose.animation.core.animateFloatAsState(
targetValue = if (expanded) 14f else 12f,
animationSpec = androidx.compose.animation.core.tween(BitchatMotion.STANDARD_MS),
label = "hdrTitle",
)
val spec = androidx.compose.animation.core.tween<androidx.compose.ui.unit.Dp>(
BitchatMotion.STANDARD_MS
)
val iconSize by androidx.compose.animation.core.animateDpAsState(
targetValue = if (expanded) 16.dp else 11.dp, animationSpec = spec, label = "hdrIcon"
)
val titleSize by androidx.compose.animation.core.animateDpAsState(
targetValue = if (expanded) 15.dp else 11.dp, animationSpec = spec, label = "hdrTitle"
)
val vPadding by androidx.compose.animation.core.animateDpAsState(
targetValue = if (expanded) 6.dp else 1.dp, animationSpec = spec, label = "hdrPad"
)
// The entire header region opens the People screen. When there are unread DMs the
// title gives way so the people and mail icons (with counts) fit side by side on the
// round screen instead of clipping at the edges.
WearChatHeader(
fontSize = titleSize,
onClickLabel = "Open people",
onClick = onOpenPeople,
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { onOpenPeople() }
.padding(horizontal = 8.dp, vertical = vPadding),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
if (unreadDms == 0) {
Text(
text = "bitchat",
style = MaterialTheme.typography.titleSmall,
fontSize = titleSize.sp,
lineHeight = (titleSize * 1.3f).sp,
fontSize = with(androidx.compose.ui.platform.LocalDensity.current) { titleSize.toSp() },
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false).padding(end = 2.dp),
modifier = Modifier.padding(end = 8.dp)
)
}
Icon(
imageVector = Icons.Filled.People,
contentDescription = "people",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(iconSize),
modifier = Modifier.size(iconSize)
)
Text(
text = if (peerCount > 99) "99+" else "$peerCount",
text = "$peerCount",
style = MaterialTheme.typography.bodySmall,
fontSize = 12.sp,
lineHeight = (titleSize * 1.3f).sp,
maxLines = 1,
fontSize = with(androidx.compose.ui.platform.LocalDensity.current) {
(iconSize.value * 0.85f).dp.toSp()
},
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 2.dp),
modifier = Modifier.padding(start = 2.dp)
)
if (unreadDms > 0) {
Icon(
imageVector = Icons.Filled.MailOutline,
contentDescription = "$unreadDms unread messages",
tint = LocalBitchatPalette.current.accentOrange,
modifier = Modifier.padding(start = 6.dp).size(iconSize),
modifier = Modifier
.padding(start = 6.dp)
.size(iconSize)
)
Text(
text = if (unreadDms > 99) "99+" else "$unreadDms",
text = "$unreadDms",
style = MaterialTheme.typography.bodySmall,
fontSize = 12.sp,
lineHeight = (titleSize * 1.3f).sp,
maxLines = 1,
fontSize = with(androidx.compose.ui.platform.LocalDensity.current) {
(iconSize.value * 0.85f).dp.toSp()
},
color = LocalBitchatPalette.current.accentOrange,
modifier = Modifier.padding(start = 2.dp),
modifier = Modifier.padding(start = 2.dp)
)
}
}
@ -240,7 +240,8 @@ fun MessageItem(
)
Text(
text = " ${formatTime(message.timestamp)}",
style = ChatVisualTokens.TimestampStyle,
style = ChatVisualTokens.SystemActionStyle,
fontSize = 9.sp,
color = palette.textTertiary
)
}

View File

@ -1,8 +1,11 @@
package com.bitchat.watch.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusable
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
@ -17,6 +20,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
@ -28,8 +32,6 @@ import androidx.wear.compose.foundation.rotary.rotaryScrollable
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.text.style.TextOverflow
import androidx.compose.foundation.lazy.items
import androidx.wear.compose.material3.MaterialTheme
import androidx.wear.compose.material3.Icon
@ -138,58 +140,59 @@ private fun DmHeader(
expanded: Boolean,
isFavorite: Boolean,
isVerified: Boolean,
onClick: () -> Unit,
onClick: () -> Unit
) {
val palette = LocalBitchatPalette.current
// Floating title row: full-size at the newest messages, shrinks to its dense form
// while scrolling up into history. Rendered as an overlay, so the animation only
// relayouts this row, never the message list.
val spec =
androidx.compose.animation.core.tween<androidx.compose.ui.unit.Dp>(
BitchatMotion.STANDARD_MS
)
val headerIconSize by
androidx.compose.animation.core.animateDpAsState(
targetValue = if (expanded) 14.dp else 12.dp,
animationSpec = spec,
label = "dmHdrIcon",
)
val headerTitleSize by
androidx.compose.animation.core.animateFloatAsState(
targetValue = if (expanded) 14f else 12f,
animationSpec = androidx.compose.animation.core.tween(BitchatMotion.STANDARD_MS),
label = "dmHdrTitle",
)
val spec = androidx.compose.animation.core.tween<androidx.compose.ui.unit.Dp>(
BitchatMotion.STANDARD_MS
)
val headerIconSize by androidx.compose.animation.core.animateDpAsState(
targetValue = if (expanded) 16.dp else 11.dp, animationSpec = spec, label = "dmHdrIcon"
)
val headerTitleSize by androidx.compose.animation.core.animateDpAsState(
targetValue = if (expanded) 15.dp else 11.dp, animationSpec = spec, label = "dmHdrTitle"
)
val headerVPadding by androidx.compose.animation.core.animateDpAsState(
targetValue = if (expanded) 6.dp else 1.dp, animationSpec = spec, label = "dmHdrPad"
)
WearChatHeader(
fontSize = headerTitleSize,
onClickLabel = "Open user details",
onClick = onClick,
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(
onClickLabel = "Open user details",
onClick = onClick
)
.padding(horizontal = 8.dp, vertical = headerVPadding),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = nickname,
style = MaterialTheme.typography.titleSmall,
fontSize = headerTitleSize.sp,
lineHeight = (headerTitleSize * 1.3f).sp,
fontSize = with(androidx.compose.ui.platform.LocalDensity.current) {
headerTitleSize.toSp()
},
fontWeight = FontWeight.Bold,
color = colorForPeer(nickname + peerID, palette),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
color = colorForPeer(nickname + peerID, palette)
)
NoiseLockIcon(
state =
if (sessionEstablished) NoiseSessionUiState.Established
else NoiseSessionUiState.Handshaking,
state = if (sessionEstablished) NoiseSessionUiState.Established
else NoiseSessionUiState.Handshaking,
size = headerIconSize,
modifier = Modifier.padding(start = 5.dp),
modifier = Modifier.padding(start = 5.dp)
)
if (isFavorite) {
Icon(
painter = painterResource(R.drawable.ic_spec_star_filled),
contentDescription = "Favorite",
tint = palette.accentOrange,
modifier = Modifier.padding(start = 4.dp).size(headerIconSize),
modifier = Modifier
.padding(start = 4.dp)
.size(headerIconSize)
)
}
if (isVerified) {
@ -197,7 +200,9 @@ private fun DmHeader(
imageVector = Icons.Filled.Verified,
contentDescription = "Verified",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 4.dp).size(headerIconSize),
modifier = Modifier
.padding(start = 4.dp)
.size(headerIconSize)
)
}
}

View File

@ -1,7 +1,10 @@
package com.bitchat.watch.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
@ -44,7 +47,7 @@ fun NicknameSetupScreen(
title: String = "bitchat",
subtitle: String = "Pick a nickname",
confirmLabel: String = "Join the mesh",
onConfirm: (String) -> Unit,
onConfirm: (String) -> Unit
) {
val palette = LocalBitchatPalette.current
// Pre-fill with the cursor at the end of the existing name, not the start.
@ -52,7 +55,7 @@ fun NicknameSetupScreen(
mutableStateOf(
TextFieldValue(
text = initialNickname,
selection = TextRange(initialNickname.length),
selection = TextRange(initialNickname.length)
)
)
}
@ -61,78 +64,71 @@ fun NicknameSetupScreen(
LaunchedEffect(Unit) { focusRequester.requestFocus() }
WearFormScreen {
item {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
)
}
item {
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall,
color = palette.textTertiary,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 4.dp, bottom = 10.dp),
)
}
item {
BasicTextField(
value = name,
onValueChange = { newValue ->
val trimmed = newValue.text.trim().take(24)
name =
if (trimmed == newValue.text) {
newValue
} else {
newValue.copy(text = trimmed, selection = TextRange(trimmed.length))
}
},
singleLine = true,
textStyle =
ChatVisualTokens.MessageBodyStyle.copy(
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center,
),
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions =
KeyboardActions(
onDone = {
keyboardController?.hide()
}
),
modifier =
Modifier.fillMaxWidth()
.focusRequester(focusRequester)
.clip(RoundedCornerShape(18.dp))
.background(palette.inputSurface)
.padding(horizontal = 12.dp, vertical = 8.dp),
decorationBox = { innerTextField ->
Box(contentAlignment = Alignment.Center) {
if (name.text.isEmpty()) {
Text(
text = "Nickname",
style = ChatVisualTokens.MessageBodyStyle,
color = palette.textTertiary,
)
}
innerTextField()
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 20.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall,
color = palette.textTertiary,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 4.dp, bottom = 10.dp)
)
BasicTextField(
value = name,
onValueChange = { newValue ->
val trimmed = newValue.text.trim().take(24)
name = if (trimmed == newValue.text) {
newValue
} else {
newValue.copy(text = trimmed, selection = TextRange(trimmed.length))
}
},
singleLine = true,
textStyle = ChatVisualTokens.MessageBodyStyle.copy(
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center
),
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = {
keyboardController?.hide()
}),
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester)
.clip(RoundedCornerShape(18.dp))
.background(palette.inputSurface)
.padding(horizontal = 12.dp, vertical = 8.dp),
decorationBox = { innerTextField ->
Box(contentAlignment = Alignment.Center) {
if (name.text.isEmpty()) {
Text(
text = "Nickname",
style = ChatVisualTokens.MessageBodyStyle,
color = palette.textTertiary
)
}
},
)
}
item {
Button(
onClick = { if (name.text.isNotBlank()) onConfirm(name.text.trim()) },
enabled = name.text.isNotBlank(),
modifier = Modifier.padding(top = 10.dp),
) {
Text(confirmLabel, textAlign = TextAlign.Center)
innerTextField()
}
}
)
Button(
onClick = { if (name.text.isNotBlank()) onConfirm(name.text.trim()) },
enabled = name.text.isNotBlank(),
modifier = Modifier.padding(top = 10.dp)
) {
Text(confirmLabel)
}
}
}

View File

@ -134,7 +134,7 @@ fun TextInputScreen(onSend: (String) -> Unit) {
}
)
},
modifier = Modifier.size(48.dp)
modifier = Modifier.size(38.dp)
) {
Icon(
imageVector = Icons.Filled.Mic,
@ -145,7 +145,7 @@ fun TextInputScreen(onSend: (String) -> Unit) {
IconButton(
onClick = { send() },
enabled = text.isNotBlank(),
modifier = Modifier.size(48.dp)
modifier = Modifier.size(38.dp)
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Send,

View File

@ -18,6 +18,7 @@ import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.wear.compose.foundation.lazy.ScalingLazyColumn
import androidx.wear.compose.foundation.lazy.rememberScalingLazyListState
@ -44,7 +45,7 @@ fun UserDetailScreen(
WearPeerIdentityState.snapshot(peerID, mesh)
}
val nickname = mesh?.getPeerNickname(peerID) ?: peerID.take(8)
val listState = rememberScalingLazyListState(initialCenterItemIndex = 0)
val listState = rememberScalingLazyListState()
val palette = LocalBitchatPalette.current
ScreenScaffold(scrollState = listState) { scaffoldPadding ->
@ -52,13 +53,12 @@ fun UserDetailScreen(
ScalingLazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
autoCentering = null,
contentPadding = scaffoldPadding
.withAdditionalPadding(
layoutDirection = layoutDirection,
horizontal = 10.dp
horizontal = 10.dp,
vertical = 8.dp
)
.withVerticalClearance(layoutDirection, top = 28.dp, bottom = 28.dp)
) {
item {
ListHeader {
@ -71,7 +71,8 @@ fun UserDetailScreen(
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
color = colorForPeer(nickname + peerID, palette),
textAlign = TextAlign.Center
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = "User details",
@ -173,13 +174,13 @@ fun UserDetailScreen(
text = if (identity.isVerified) {
"Identity verified"
} else {
"Identity code"
"Verification code"
},
style = ChatVisualTokens.SenderStyle,
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = "Compare identity codes",
text = "Compare cryptographic fingerprints",
style = ChatVisualTokens.SystemActionStyle,
color = palette.textTertiary
)

View File

@ -40,7 +40,7 @@ fun VerificationCodeScreen(peerID: String) {
WearPeerIdentityState.snapshot(peerID, mesh)
}
val myFingerprint = WearPeerIdentityState.myFingerprint(mesh)
val listState = rememberScalingLazyListState(initialCenterItemIndex = 0)
val listState = rememberScalingLazyListState()
val palette = LocalBitchatPalette.current
ScreenScaffold(scrollState = listState) { scaffoldPadding ->
@ -48,13 +48,12 @@ fun VerificationCodeScreen(peerID: String) {
ScalingLazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
autoCentering = null,
contentPadding = scaffoldPadding
.withAdditionalPadding(
layoutDirection = layoutDirection,
horizontal = 10.dp
horizontal = 10.dp,
vertical = 8.dp
)
.withVerticalClearance(layoutDirection, top = 28.dp, bottom = 28.dp)
) {
item {
ListHeader {
@ -127,8 +126,7 @@ fun VerificationCodeScreen(peerID: String) {
"Remove verification"
} else {
"Mark verified"
},
textAlign = TextAlign.Center
}
)
}
}
@ -160,8 +158,8 @@ private fun FingerprintCard(
text = fingerprint?.let(::formatVerificationCode) ?: "Handshake pending",
style = MaterialTheme.typography.bodySmall.copy(
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
lineHeight = 16.sp
fontSize = 10.sp,
lineHeight = 13.sp
),
color = if (fingerprint == null) {
palette.accentOrange
@ -180,6 +178,6 @@ fun formatVerificationCode(fingerprint: String): String {
return fingerprint
.uppercase()
.chunked(4)
.chunked(2)
.chunked(4)
.joinToString("\n") { line -> line.joinToString(" ") }
}

View File

@ -1,75 +0,0 @@
package com.bitchat.watch.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.wear.compose.material3.MaterialTheme
/** The title is outside the scrolling list, so it needs its own physical display bounds. */
@Composable
internal fun WearChatHeader(
fontSize: Float,
onClickLabel: String,
onClick: () -> Unit,
content: @Composable RowScope.() -> Unit,
) {
val configuration = LocalConfiguration.current
val lineHeight = with(LocalDensity.current) { (fontSize * 1.3f).sp.toDp() }
val rowHeight = maxOf(48.dp, lineHeight)
val top = (rowHeight - lineHeight) / 2
val background = MaterialTheme.colorScheme.background
// Follow the existing title animation without animating layout or the hit target.
val expansion = ((fontSize - 12f) / 2f).coerceIn(0f, 1f)
val fadeEnd = maxOf((36f + 8f * expansion).dp, top + lineHeight + 4.dp)
BoxWithConstraints(
Modifier.fillMaxWidth().drawWithCache {
val brush =
Brush.verticalGradient(
0f to background,
0.65f to background.copy(alpha = 0.95f),
1f to Color.Transparent,
endY = fadeEnd.toPx(),
)
onDrawBehind { drawRect(brush) }
},
contentAlignment = Alignment.TopCenter,
) {
val safeWidth =
if (configuration.isScreenRound) {
roundBandWidth(
maxWidth.value,
configuration.screenHeightDp.toFloat(),
top.value,
(top + lineHeight).value,
)
.dp - 8.dp
} else {
maxWidth - 16.dp
}
Row(
modifier =
Modifier.width(safeWidth.coerceAtLeast(48.dp))
.height(rowHeight)
.clickable(role = Role.Button, onClickLabel = onClickLabel, onClick = onClick),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
content = content,
)
}
}

View File

@ -1,16 +0,0 @@
package com.bitchat.watch.ui
import kotlin.math.abs
import kotlin.math.min
import kotlin.math.sqrt
/** Width of the narrowest chord across a centered horizontal band in a round display. */
internal fun roundBandWidth(width: Float, height: Float, top: Float, bottom: Float): Float {
val radius = min(width, height) / 2f
val distance = maxOf(abs(top - height / 2f), abs(bottom - height / 2f))
return 2f * sqrt((radius * radius - distance * distance).coerceAtLeast(0f))
}
/** A centered square whose four corners fit inside the physical circle, with a small inset. */
internal fun roundContentSide(width: Float, height: Float): Float =
(min(width, height) / sqrt(2f) - 4f).coerceAtLeast(0f)

View File

@ -1,30 +0,0 @@
package com.bitchat.watch.ui
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.unit.dp
import androidx.wear.compose.foundation.lazy.ScalingLazyColumn
import androidx.wear.compose.foundation.lazy.ScalingLazyListScope
import androidx.wear.compose.foundation.lazy.rememberScalingLazyListState
import androidx.wear.compose.material3.ScreenScaffold
/** Independently scrollable form items remain reachable on small watches and at large fonts. */
@Composable
internal fun WearFormScreen(content: ScalingLazyListScope.() -> Unit) {
val state = rememberScalingLazyListState(initialCenterItemIndex = 0)
val direction = LocalLayoutDirection.current
ScreenScaffold(scrollState = state) { padding ->
ScalingLazyColumn(
state = state,
modifier = Modifier.fillMaxSize(),
autoCentering = null,
contentPadding =
padding
.withAdditionalPadding(direction, horizontal = 14.dp)
.withVerticalClearance(direction, top = 28.dp, bottom = 28.dp),
content = content,
)
}
}

View File

@ -8,7 +8,6 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
@ -44,7 +43,6 @@ import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
@ -55,7 +53,6 @@ import androidx.wear.compose.material3.Text
import com.bitchat.android.features.voice.AudioWaveformExtractor
import com.bitchat.android.features.voice.VoiceWaveformCache
import com.bitchat.watch.ui.theme.ChatVisualTokens
import com.bitchat.watch.ui.roundContentSide
import com.bitchat.watch.ui.theme.LocalBitchatPalette
import kotlinx.coroutines.delay
import java.io.File
@ -95,7 +92,7 @@ fun FullScreenImageViewer(path: String, onClose: () -> Unit) {
onDismissRequest = onClose,
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
BoxWithConstraints(
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black)
@ -104,16 +101,11 @@ fun FullScreenImageViewer(path: String, onClose: () -> Unit) {
) {
val bitmap = remember(path) { BitmapFactory.decodeFile(path) }
if (bitmap != null) {
val imageModifier = if (LocalConfiguration.current.isScreenRound) {
Modifier.size(roundContentSide(maxWidth.value, maxHeight.value).dp)
} else {
Modifier.fillMaxSize()
}
Image(
painter = BitmapPainter(bitmap.asImageBitmap()),
contentDescription = "image fullscreen",
contentScale = ContentScale.Fit,
modifier = imageModifier
modifier = Modifier.fillMaxSize()
)
}
Icon(
@ -122,7 +114,7 @@ fun FullScreenImageViewer(path: String, onClose: () -> Unit) {
tint = Color.White.copy(alpha = 0.7f),
modifier = Modifier
.align(Alignment.TopCenter)
.padding(top = 8.dp)
.padding(top = 24.dp)
.size(20.dp)
)
}

View File

@ -37,9 +37,7 @@ object ChatVisualTokens {
val SystemActionStyle = TextStyle(
fontFamily = BitchatFontFamily,
fontWeight = FontWeight.Medium,
fontSize = 12.sp,
lineHeight = 15.sp,
fontSize = 11.sp,
lineHeight = 14.sp,
)
val TimestampStyle = SystemActionStyle.copy(fontSize = 10.sp, lineHeight = 12.sp)
}

View File

@ -11,131 +11,99 @@ import org.junit.Test
class ChatAutoScrollTest {
@Test
fun `append waits for active gesture and rechecks history intent`() = runTest {
val scrolling = MutableStateFlow(true)
var followsNewest = true
var scrollCount = 0
val job =
launch(start = CoroutineStart.UNDISPATCHED) {
followNewestWhenIdle(scrolling, { followsNewest }) { scrollCount++ }
}
assertFalse(job.isCompleted)
assertEquals(0, scrollCount)
followsNewest = false
scrolling.value = false
job.join()
assertEquals(0, scrollCount)
fun `new scroll range from appended message keeps follow intent`() {
val updated = updatedChatScrollIntent(
current = ChatScrollIntentState(),
snapshot = ChatScrollSnapshot(
canScrollForward = true,
isScrollInProgress = false,
position = 100
),
previousPosition = 100
)
assertEquals(ChatScrollIntentState(), updated)
}
@Test
fun `append follows after gesture settles when user remains at newest`() = runTest {
val scrolling = MutableStateFlow(true)
var scrollCount = 0
val job =
launch(start = CoroutineStart.UNDISPATCHED) {
followNewestWhenIdle(scrolling, { true }) { scrollCount++ }
}
assertEquals(0, scrollCount)
scrolling.value = false
job.join()
assertEquals(1, scrollCount)
}
fun `user scroll away disables follow until list reaches newest again`() {
val browsingHistory = updatedChatScrollIntent(
current = ChatScrollIntentState(),
snapshot = ChatScrollSnapshot(
canScrollForward = true,
isScrollInProgress = true,
position = 60
),
previousPosition = 100
)
assertFalse(browsingHistory.followsNewest)
assertFalse(browsingHistory.controlsVisible)
private fun move(
state: ChatScrollIntentState,
dp: Float,
user: Boolean = true,
newest: Boolean = false,
) = updatedChatScrollIntent(state, dp, user, newest)
@Test
fun `append and programmatic movement never change intent`() {
val docked = ChatScrollIntentState()
assertEquals(docked, move(docked, 1000f, user = false))
val history = ChatScrollIntentState(false, false)
assertEquals(history, move(history, 1000f, user = false))
val dockedAgain = updatedChatScrollIntent(
current = browsingHistory,
snapshot = ChatScrollSnapshot(
canScrollForward = false,
isScrollInProgress = false,
position = 200
),
previousPosition = 60
)
assertEquals(ChatScrollIntentState(), dockedAgain)
}
@Test
fun `first consumed movement away suspends following before controls hide`() {
val state = move(ChatScrollIntentState(), -1f)
assertFalse(state.followsNewest)
assertEquals(true, state.controlsVisible)
assertFalse(move(state, -11f).controlsVisible)
}
@Test
fun `deliberate reversal reveals controls but does not resume following`() {
val history = move(ChatScrollIntentState(), -12f)
val almost = move(history, 23f)
assertFalse(almost.controlsVisible)
val revealed = move(almost, 1f)
assertEquals(true, revealed.controlsVisible)
assertFalse(revealed.followsNewest)
assertEquals(ChatScrollIntentState(), move(revealed, 1f, newest = true))
}
@Test
fun `jitter does not accumulate into repeated toggles`() {
var state = move(ChatScrollIntentState(), -12f)
repeat(100) {
state = move(state, 3f)
state = move(state, -3f)
}
assertFalse(state.controlsVisible)
assertEquals(0f, state.reversalDp)
}
@Test
fun `pauses and discrete crown ticks retain net movement`() {
fun `slow scroll away accumulates intent across sub-threshold updates`() {
var state = ChatScrollIntentState()
repeat(4) {
state = move(state, -3f)
state = move(state, 0f, user = false)
var previousPosition = 100
listOf(94, 88, 82, 76).forEach { position ->
state = updatedChatScrollIntent(
current = state,
snapshot = ChatScrollSnapshot(
canScrollForward = true,
isScrollInProgress = true,
position = position
),
previousPosition = previousPosition
)
previousPosition = position
state = updatedChatScrollIntent(
current = state,
snapshot = ChatScrollSnapshot(
canScrollForward = true,
isScrollInProgress = false,
position = position
),
previousPosition = previousPosition
)
}
assertFalse(state.controlsVisible)
repeat(8) {
state = move(state, 3f)
state = move(state, 0f, user = false)
}
assertEquals(true, state.controlsVisible)
assertFalse(state.followsNewest)
assertFalse(state.controlsVisible)
assertEquals(0, state.accumulatedDeltaPx)
}
@Test
fun `fling preserves controls until newest is reached`() {
val history = move(ChatScrollIntentState(), -12f)
assertEquals(history, move(history, 1000f, user = false))
assertEquals(history, move(history, -1000f, user = false))
assertEquals(ChatScrollIntentState(), move(history, 1f, user = false, newest = true))
}
fun `slow scroll toward newest reveals controls without restoring follow early`() {
var state = ChatScrollIntentState(followsNewest = false, controlsVisible = false)
var previousPosition = 60
@Test
fun `consumed distance is independent of item boundaries and event chunking`() {
val initial = ChatScrollIntentState()
val oneEvent = move(initial, -12f)
val acrossRows =
listOf(-2f, -3f, -1f, -6f).fold(initial) { state, delta ->
move(state, delta)
}
assertEquals(oneEvent, acrossRows)
}
@Test
fun `pixel distances normalize to the same dp thresholds`() {
for (density in listOf(1f, 1.6875f, 2f, 3f)) {
val history = move(ChatScrollIntentState(), (-12f * density) / density)
assertFalse(history.controlsVisible)
assertEquals(true, move(history, (24f * density) / density).controlsVisible)
listOf(66, 72, 78, 84).forEach { position ->
state = updatedChatScrollIntent(
current = state,
snapshot = ChatScrollSnapshot(
canScrollForward = true,
isScrollInProgress = true,
position = position
),
previousPosition = previousPosition
)
previousPosition = position
}
}
@Test
fun `invalid and unconsumed input is ignored`() {
val initial = ChatScrollIntentState()
assertEquals(initial, move(initial, Float.NaN))
assertEquals(initial, move(initial, Float.POSITIVE_INFINITY))
assertEquals(initial, move(initial, 0f))
assertFalse(state.followsNewest)
assertEquals(true, state.controlsVisible)
assertEquals(0, state.accumulatedDeltaPx)
}
@Test
@ -143,16 +111,15 @@ class ChatAutoScrollTest {
val measuredLayouts = MutableStateFlow(MeasuredChatLayout(3, null))
var scrollCount = 0
val scrollJob =
launch(start = CoroutineStart.UNDISPATCHED) {
scrollToNewestAfterItemsMeasured(
expectedItemCount = 4,
expectedSingleMessageKey = null,
measuredLayouts = measuredLayouts,
) {
scrollCount += 1
}
val scrollJob = launch(start = CoroutineStart.UNDISPATCHED) {
scrollToNewestAfterItemsMeasured(
expectedItemCount = 4,
expectedSingleMessageKey = null,
measuredLayouts = measuredLayouts
) {
scrollCount += 1
}
}
assertFalse(scrollJob.isCompleted)
assertEquals(0, scrollCount)
@ -171,7 +138,7 @@ class ChatAutoScrollTest {
scrollToNewestAfterItemsMeasured(
expectedItemCount = 4,
expectedSingleMessageKey = null,
measuredLayouts = measuredLayouts,
measuredLayouts = measuredLayouts
) {
scrollCount += 1
}
@ -182,31 +149,28 @@ class ChatAutoScrollTest {
@Test
fun `first message waits past stale empty placeholder layout`() = runTest {
val messageKey = "first-message"
val measuredLayouts =
MutableStateFlow(
MeasuredChatLayout(itemCount = 1, singleVisibleItemKey = "empty-placeholder")
)
val measuredLayouts = MutableStateFlow(
MeasuredChatLayout(itemCount = 1, singleVisibleItemKey = "empty-placeholder")
)
var scrollCount = 0
val scrollJob =
launch(start = CoroutineStart.UNDISPATCHED) {
scrollToNewestAfterItemsMeasured(
expectedItemCount = 1,
expectedSingleMessageKey = messageKey,
measuredLayouts = measuredLayouts,
) {
scrollCount += 1
}
val scrollJob = launch(start = CoroutineStart.UNDISPATCHED) {
scrollToNewestAfterItemsMeasured(
expectedItemCount = 1,
expectedSingleMessageKey = messageKey,
measuredLayouts = measuredLayouts
) {
scrollCount += 1
}
}
assertFalse(scrollJob.isCompleted)
assertEquals(0, scrollCount)
measuredLayouts.value =
MeasuredChatLayout(
itemCount = 1,
singleVisibleItemKey = messageKey,
)
measuredLayouts.value = MeasuredChatLayout(
itemCount = 1,
singleVisibleItemKey = messageKey
)
scrollJob.join()
assertEquals(1, scrollCount)

View File

@ -1,51 +0,0 @@
package com.bitchat.watch.ui
import com.bitchat.watch.ui.theme.ChatVisualTokens
import kotlin.math.pow
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class WearDisplayGeometryTest {
@Test
fun `header band corners stay inside the circle at supported text sizes`() {
for (diameter in listOf(192f, 228f, 240f)) {
for (scale in listOf(0.94f, 1f, 1.24f, 1.3f)) {
for (titleSize in listOf(12f, 13f, 14f)) {
val lineHeight = titleSize * 1.3f * scale
val top = (maxOf(48f, lineHeight) - lineHeight) / 2f
val width = roundBandWidth(diameter, diameter, top, top + lineHeight)
val radius = diameter / 2f
for (y in listOf(top, top + lineHeight)) {
assertTrue(
(width / 2).pow(2) + (y - radius).pow(2) <= radius.pow(2) + 0.01f
)
}
}
}
}
}
@Test
fun `out of display bands have no usable width`() {
assertEquals(0f, roundBandWidth(192f, 192f, -1f, 20f))
assertEquals(0f, roundBandWidth(192f, 192f, 180f, 193f))
}
@Test
fun `image and recording safe square fits all four corners`() {
for (diameter in listOf(192f, 228f, 240f)) {
val side = roundContentSide(diameter, diameter)
assertTrue(side > 0)
assertTrue(2 * (side / 2).pow(2) < (diameter / 2).pow(2))
}
}
@Test
fun `essential shared text is at least twelve sp`() {
assertTrue(ChatVisualTokens.SystemActionStyle.fontSize.value >= 12f)
assertTrue(ChatVisualTokens.MessageBodyStyle.fontSize.value >= 12f)
assertTrue(ChatVisualTokens.SenderStyle.fontSize.value >= 12f)
assertTrue(ChatVisualTokens.TimestampStyle.fontSize.value >= 10f)
}
}

View File

@ -32,7 +32,7 @@ class WearPeerIdentityStateTest {
val formatted = formatVerificationCode(fingerprint)
assertEquals(fingerprint.uppercase(), formatted.filterNot(Char::isWhitespace))
assertEquals(8, formatted.lines().size)
assertEquals(List(8) { 2 }, formatted.lines().map { it.split(" ").size })
assertEquals(4, formatted.lines().size)
assertEquals(listOf(4, 4, 4, 4), formatted.lines().map { it.split(" ").size })
}
}