mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-09-19 04:59:59 +00:00
Merge 227407c3e70ddb6b36874828c33d99266bba73d5 into c127eb83ab94c069c32d37530d2faecd381cd2a8
This commit is contained in:
commit
c6577e52ed
@ -40,10 +40,12 @@ class GeohashRepository(
|
||||
|
||||
@Synchronized
|
||||
fun findPubkeyByNickname(targetNickname: String): String? {
|
||||
return geoNicknames.entries.firstOrNull { (_, nickname) ->
|
||||
val base = nickname.split("#").firstOrNull() ?: nickname
|
||||
base == targetNickname
|
||||
}?.key
|
||||
// Same unique-or-nil rule as mesh: colliding nicks must use the
|
||||
// people-list suffix (last four hex characters of the pubkey).
|
||||
return com.bitchat.android.ui.uniquePeerIDForNickname(
|
||||
targetNickname,
|
||||
geoNicknames,
|
||||
) { pubkey -> "#${pubkey.takeLast(4)}" }
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
|
||||
@ -241,9 +241,11 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
||||
displayName to PeerIdentity.nostr(person.id)
|
||||
}
|
||||
} else {
|
||||
val duplicateNames = duplicateMeshBaseNames(peerNicknames)
|
||||
connectedPeers.mapNotNull { peerID ->
|
||||
peerNicknames[peerID]?.let { displayName ->
|
||||
displayName to PeerIdentity.mesh(peerID)
|
||||
disambiguatedMeshDisplayName(peerID, displayName, duplicateNames) to
|
||||
PeerIdentity.mesh(peerID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -342,6 +342,90 @@ fun splitSuffix(name: String): Pair<String, String> {
|
||||
return Pair(name, "")
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a typed nickname to a mesh peer ID.
|
||||
*
|
||||
* An unsuffixed name is accepted only when it identifies exactly one peer.
|
||||
* Colliding nicknames must be disambiguated with the `#xxxx` people-list suffix
|
||||
* (first four hex characters of the peer ID), matching mention coloring.
|
||||
*/
|
||||
internal sealed class NicknameResolution {
|
||||
data class Unique(val peerID: String) : NicknameResolution()
|
||||
data object None : NicknameResolution()
|
||||
data object Ambiguous : NicknameResolution()
|
||||
}
|
||||
|
||||
internal fun resolvePeerIDForNickname(
|
||||
query: String,
|
||||
nicknames: Map<String, String>,
|
||||
suffixOf: (String) -> String = { "#${it.take(4)}" },
|
||||
): NicknameResolution {
|
||||
val target = query.trim().removePrefix("@")
|
||||
if (target.isEmpty()) return NicknameResolution.None
|
||||
|
||||
val (base, suffix) = splitSuffix(target)
|
||||
val matches = linkedSetOf<String>()
|
||||
for ((peerID, nickname) in nicknames) {
|
||||
val nick = nickname.trim()
|
||||
if (nick.equals(target, ignoreCase = true)) {
|
||||
matches.add(peerID)
|
||||
continue
|
||||
}
|
||||
if (suffix.isNotEmpty() && nick.equals(base, ignoreCase = true)) {
|
||||
if (suffixOf(peerID).equals(suffix, ignoreCase = true)) {
|
||||
matches.add(peerID)
|
||||
}
|
||||
}
|
||||
}
|
||||
return when (matches.size) {
|
||||
0 -> NicknameResolution.None
|
||||
1 -> NicknameResolution.Unique(matches.first())
|
||||
else -> NicknameResolution.Ambiguous
|
||||
}
|
||||
}
|
||||
|
||||
internal fun uniquePeerIDForNickname(
|
||||
query: String,
|
||||
nicknames: Map<String, String>,
|
||||
suffixOf: (String) -> String = { "#${it.take(4)}" },
|
||||
): String? = (resolvePeerIDForNickname(query, nicknames, suffixOf) as? NicknameResolution.Unique)?.peerID
|
||||
|
||||
/** First four hex characters of a mesh peer ID, matching mention coloring. */
|
||||
internal fun meshPeerSuffix(peerID: String): String = "#${peerID.take(4)}"
|
||||
|
||||
internal fun duplicateMeshBaseNames(nicknames: Map<String, String>): Set<String> =
|
||||
nicknames.values
|
||||
.groupingBy { splitSuffix(it).first.lowercase(Locale.ROOT) }
|
||||
.eachCount()
|
||||
.filterValues { it > 1 }
|
||||
.keys
|
||||
|
||||
/**
|
||||
* People-list / autocomplete suffix for a mesh peer.
|
||||
*
|
||||
* Mesh nicknames are stored unsuffixed, unlike some geohash announcements, so a collision
|
||||
* has to derive `#xxxx` from the peer ID. Preserve an already-embedded suffix if present.
|
||||
*/
|
||||
internal fun meshIdentitySuffix(
|
||||
peerID: String,
|
||||
displayName: String,
|
||||
showHashSuffix: Boolean,
|
||||
): String {
|
||||
if (!showHashSuffix || displayName == "You") return ""
|
||||
val announcedSuffix = splitSuffix(displayName).second
|
||||
return announcedSuffix.ifEmpty { meshPeerSuffix(peerID) }
|
||||
}
|
||||
|
||||
internal fun disambiguatedMeshDisplayName(
|
||||
peerID: String,
|
||||
displayName: String,
|
||||
duplicateBaseNames: Set<String>,
|
||||
): String {
|
||||
val baseName = splitSuffix(displayName).first
|
||||
val showSuffix = baseName.lowercase(Locale.ROOT) in duplicateBaseNames
|
||||
return baseName + meshIdentitySuffix(peerID, displayName, showSuffix)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a case-insensitive mention-token lookup from canonical peer identities.
|
||||
*
|
||||
|
||||
@ -1075,7 +1075,7 @@ class ChatViewModel(
|
||||
// MARK: - Utility Functions
|
||||
|
||||
fun getPeerIDForNickname(nickname: String): String? {
|
||||
return mesh.getPeerNicknames().entries.find { it.value == nickname }?.key
|
||||
return uniquePeerIDForNickname(nickname, mesh.getPeerNicknames())
|
||||
}
|
||||
|
||||
fun toggleFavorite(peerID: String) {
|
||||
|
||||
@ -86,42 +86,53 @@ class CommandProcessor(
|
||||
private fun handleMessageCommand(parts: List<String>, meshService: MeshService, viewModel: ChatViewModel?) {
|
||||
if (parts.size > 1) {
|
||||
val targetName = parts[1].removePrefix("@")
|
||||
val peerID = getPeerIDForNickname(targetName, meshService)
|
||||
|
||||
if (peerID != null) {
|
||||
val success = privateChatManager.startPrivateChat(peerID, meshService)
|
||||
|
||||
if (success) {
|
||||
if (parts.size > 2) {
|
||||
val messageContent = parts.drop(2).joinToString(" ")
|
||||
val recipientNickname = getPeerNickname(peerID, meshService)
|
||||
sendPrivateMessage(
|
||||
messageContent,
|
||||
peerID,
|
||||
recipientNickname,
|
||||
state.getNicknameValue(),
|
||||
getMyPeerID(meshService),
|
||||
meshService,
|
||||
viewModel
|
||||
)
|
||||
} else {
|
||||
val systemMessage = BitchatMessage(
|
||||
sender = "system",
|
||||
content = "started private chat with $targetName",
|
||||
timestamp = Date(),
|
||||
isRelay = false
|
||||
)
|
||||
messageManager.addMessage(systemMessage)
|
||||
when (val resolved = resolvePeerIDForNickname(targetName, meshService.getPeerNicknames())) {
|
||||
is NicknameResolution.Unique -> {
|
||||
val peerID = resolved.peerID
|
||||
val success = privateChatManager.startPrivateChat(peerID, meshService)
|
||||
|
||||
if (success) {
|
||||
if (parts.size > 2) {
|
||||
val messageContent = parts.drop(2).joinToString(" ")
|
||||
val recipientNickname = getPeerNickname(peerID, meshService)
|
||||
sendPrivateMessage(
|
||||
messageContent,
|
||||
peerID,
|
||||
recipientNickname,
|
||||
state.getNicknameValue(),
|
||||
getMyPeerID(meshService),
|
||||
meshService,
|
||||
viewModel
|
||||
)
|
||||
} else {
|
||||
val systemMessage = BitchatMessage(
|
||||
sender = "system",
|
||||
content = "started private chat with $targetName",
|
||||
timestamp = Date(),
|
||||
isRelay = false
|
||||
)
|
||||
messageManager.addMessage(systemMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val systemMessage = BitchatMessage(
|
||||
sender = "system",
|
||||
content = "user '$targetName' not found. they may be offline or using a different nickname.",
|
||||
timestamp = Date(),
|
||||
isRelay = false
|
||||
)
|
||||
messageManager.addMessage(systemMessage)
|
||||
NicknameResolution.Ambiguous -> {
|
||||
val systemMessage = BitchatMessage(
|
||||
sender = "system",
|
||||
content = "nickname '$targetName' is used by more than one peer; use the #xxxx suffix from the people list.",
|
||||
timestamp = Date(),
|
||||
isRelay = false
|
||||
)
|
||||
messageManager.addMessage(systemMessage)
|
||||
}
|
||||
NicknameResolution.None -> {
|
||||
val systemMessage = BitchatMessage(
|
||||
sender = "system",
|
||||
content = "user '$targetName' not found. they may be offline or using a different nickname.",
|
||||
timestamp = Date(),
|
||||
isRelay = false
|
||||
)
|
||||
messageManager.addMessage(systemMessage)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val systemMessage = BitchatMessage(
|
||||
@ -562,9 +573,15 @@ class CommandProcessor(
|
||||
when (val selectedChannel = viewModel.selectedLocationChannel.value) {
|
||||
is com.bitchat.android.geohash.ChannelID.Mesh,
|
||||
null -> {
|
||||
// Mesh channel: use Bluetooth mesh peer nicknames
|
||||
// Mesh channel: use Bluetooth mesh peer nicknames, with the same
|
||||
// #xxxx suffix the people list shows when two peers share a name.
|
||||
val peerNicknames = meshService.getPeerNicknames()
|
||||
peerNicknames.values.filter { it != peerNicknames[meshService.myPeerID] }
|
||||
val myPeerID = meshService.myPeerID
|
||||
val duplicateNames = duplicateMeshBaseNames(peerNicknames)
|
||||
peerNicknames.mapNotNull { (peerID, name) ->
|
||||
if (peerID == myPeerID) null
|
||||
else disambiguatedMeshDisplayName(peerID, name, duplicateNames)
|
||||
}
|
||||
}
|
||||
|
||||
is com.bitchat.android.geohash.ChannelID.Location -> {
|
||||
@ -595,7 +612,12 @@ class CommandProcessor(
|
||||
} else {
|
||||
// Fallback to mesh peers if no viewModel available
|
||||
val peerNicknames = meshService.getPeerNicknames()
|
||||
peerNicknames.values.filter { it != peerNicknames[meshService.myPeerID] }
|
||||
val myPeerID = meshService.myPeerID
|
||||
val duplicateNames = duplicateMeshBaseNames(peerNicknames)
|
||||
peerNicknames.mapNotNull { (peerID, name) ->
|
||||
if (peerID == myPeerID) null
|
||||
else disambiguatedMeshDisplayName(peerID, name, duplicateNames)
|
||||
}
|
||||
}
|
||||
|
||||
val filteredNicknames = filterMentionCandidates(peerCandidates, textAfterAt)
|
||||
@ -627,7 +649,7 @@ class CommandProcessor(
|
||||
// MARK: - Utility Functions
|
||||
|
||||
private fun getPeerIDForNickname(nickname: String, meshService: MeshService): String? {
|
||||
return meshService.getPeerNicknames().entries.find { it.value == nickname }?.key
|
||||
return uniquePeerIDForNickname(nickname, meshService.getPeerNicknames())
|
||||
}
|
||||
|
||||
private fun getPeerNickname(peerID: String, meshService: MeshService): String {
|
||||
|
||||
@ -1480,9 +1480,9 @@ private fun PeerItem(
|
||||
isDirect = isDirect
|
||||
)
|
||||
// Split display name for hashtag suffix support (iOS-compatible)
|
||||
val (baseNameRaw, suffixRaw) = splitSuffix(displayName)
|
||||
val (baseNameRaw, _) = splitSuffix(displayName)
|
||||
val baseName = truncateNickname(baseNameRaw)
|
||||
val suffix = if (showHashSuffix) suffixRaw else ""
|
||||
val suffix = meshIdentitySuffix(peerID, displayName, showHashSuffix)
|
||||
val isMe = displayName == "You" || peerID == currentNickname
|
||||
|
||||
// Get consistent peer color (iOS-compatible)
|
||||
|
||||
@ -577,7 +577,7 @@ class PrivateChatManager(
|
||||
// MARK: - Utility Functions
|
||||
|
||||
private fun getPeerIDForNickname(nickname: String, meshService: MeshService): String? {
|
||||
return meshService.getPeerNicknames().entries.find { it.value == nickname }?.key
|
||||
return uniquePeerIDForNickname(nickname, meshService.getPeerNicknames())
|
||||
}
|
||||
|
||||
private fun getPeerNickname(peerID: String, meshService: MeshService): String {
|
||||
|
||||
@ -310,6 +310,92 @@ class ChatUIUtilsTest {
|
||||
assertEquals(null, resolveMentionPeerIdentity("@alice", identities))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unsuffixed nickname is unique when only one peer uses it`() {
|
||||
val resolved = resolvePeerIDForNickname(
|
||||
"Alice",
|
||||
mapOf("aabbccdd" to "alice")
|
||||
)
|
||||
assertEquals(NicknameResolution.Unique("aabbccdd"), resolved)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unsuffixed nickname is refused when two peers share it`() {
|
||||
val resolved = resolvePeerIDForNickname(
|
||||
"alice",
|
||||
mapOf(
|
||||
"1111aaaa" to "alice",
|
||||
"2222bbbb" to "alice",
|
||||
)
|
||||
)
|
||||
assertEquals(NicknameResolution.Ambiguous, resolved)
|
||||
assertEquals(null, uniquePeerIDForNickname("alice", mapOf(
|
||||
"1111aaaa" to "alice",
|
||||
"2222bbbb" to "alice",
|
||||
)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hash suffix selects the colliding peer`() {
|
||||
val nicknames = mapOf(
|
||||
"1111aaaa" to "alice",
|
||||
"2222bbbb" to "alice",
|
||||
)
|
||||
assertEquals(
|
||||
NicknameResolution.Unique("1111aaaa"),
|
||||
resolvePeerIDForNickname("alice#1111", nicknames)
|
||||
)
|
||||
assertEquals(
|
||||
NicknameResolution.Unique("2222bbbb"),
|
||||
resolvePeerIDForNickname("ALICE#2222", nicknames)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `mesh people-list suffix is the first four hex characters of the peer id`() {
|
||||
val nicknames = mapOf(
|
||||
"1111aaaa" to "alice",
|
||||
"2222bbbb" to "alice",
|
||||
"ccccdddd" to "bob",
|
||||
)
|
||||
val duplicates = duplicateMeshBaseNames(nicknames)
|
||||
assertEquals(setOf("alice"), duplicates)
|
||||
assertEquals("alice#1111", disambiguatedMeshDisplayName("1111aaaa", "alice", duplicates))
|
||||
assertEquals("alice#2222", disambiguatedMeshDisplayName("2222bbbb", "alice", duplicates))
|
||||
assertEquals("bob", disambiguatedMeshDisplayName("ccccdddd", "bob", duplicates))
|
||||
assertEquals(
|
||||
"#1111",
|
||||
meshIdentitySuffix("1111aaaa", "alice", showHashSuffix = true)
|
||||
)
|
||||
assertEquals("", meshIdentitySuffix("1111aaaa", "alice", showHashSuffix = false))
|
||||
assertEquals("", meshIdentitySuffix("1111aaaa", "You", showHashSuffix = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unknown nickname resolves to none`() {
|
||||
assertEquals(
|
||||
NicknameResolution.None,
|
||||
resolvePeerIDForNickname("bob", mapOf("1111aaaa" to "alice"))
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `geohash last-four suffix selects the colliding pubkey`() {
|
||||
val nicknames = mapOf(
|
||||
"1111aaaa" to "alice",
|
||||
"2222bbbb" to "alice",
|
||||
)
|
||||
val lastFour: (String) -> String = { "#${it.takeLast(4)}" }
|
||||
assertEquals(
|
||||
NicknameResolution.Unique("1111aaaa"),
|
||||
resolvePeerIDForNickname("alice#aaaa", nicknames, lastFour)
|
||||
)
|
||||
assertEquals(
|
||||
NicknameResolution.Ambiguous,
|
||||
resolvePeerIDForNickname("alice", nicknames, lastFour)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `message without mentions has no background chips`() {
|
||||
val body = formatTextMessageBody(
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user