Merge 39013a4701866fdb07b4235c78ab62a1d4194848 into 1f59e814f90c3f489f48d68262cb1bf640bf6181

This commit is contained in:
Sam 2026-08-03 18:39:17 +00:00 committed by GitHub
commit 3cc8d159a2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 211 additions and 5 deletions

View File

@ -9,6 +9,11 @@
import Foundation
extension String {
/// Returns a deterministic DJB2 hash for this string.
///
/// DJB2 is a non-cryptographic hash used for stable peer colors and
/// other UI-related deterministic values. Do not use it for cryptographic
/// or security-sensitive purposes.
func djb2() -> UInt64 {
var hash: UInt64 = 5381
for b in utf8 { hash = ((hash << 5) &+ hash) &+ UInt64(b) }

View File

@ -17,15 +17,30 @@ extension String {
precomposedStringWithCanonicalMapping
}
/// Split a nickname into base and a '#abcd' suffix if present
/// Splits a nickname into base and `#abcd` suffix when present.
///
/// Mention `@` characters are removed before parsing.
///
/// Examples:
/// - `"alice#1a2b"` returns `("alice", "#1a2b")`
/// - `"@charlie#ffff"` returns `("charlie", "#ffff")`
/// - `"bob"` returns `("bob", "")`
/// - `"test##1234"` returns `("test##1234", "")`
func splitSuffix() -> (String, String) {
let name = self.replacingOccurrences(of: "@", with: "")
guard name.count >= 5 else { return (name, "") }
let suffix = String(name.suffix(5))
if suffix.first == "#", suffix.dropFirst().allSatisfy({ c in
("0"..."9").contains(String(c)) || ("a"..."f").contains(String(c)) || ("A"..."F").contains(String(c))
}) {
let base = String(name.dropLast(5))
let base = String(name.dropLast(5))
let hasValidSuffix = suffix.first == "#" && suffix.dropFirst().allSatisfy { character in
guard character.unicodeScalars.count == 1,
let scalar = character.unicodeScalars.first else {
return false
}
return (48...57).contains(scalar.value)
|| (65...70).contains(scalar.value)
|| (97...102).contains(scalar.value)
}
if hasValidSuffix, !base.contains("#") {
return (base, suffix)
}
return (name, "")

View File

@ -0,0 +1,99 @@
//
// ColorPeerTests.swift
// bitchatTests
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Testing
import SwiftUI
#if os(iOS)
import UIKit
#elseif os(macOS)
import AppKit
#endif
@testable import bitchat
@Suite(.serialized)
struct ColorPeerTests {
@Test func peerColor_resolvesRepresentativeSeeds() {
let seeds = [
"alice",
"",
"caf\u{00E9}",
"a",
String(repeating: "long-seed-", count: 12),
"nostr:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
]
for seed in seeds {
#expect(isUsableRGBA(resolvedRGBA(for: Color(peerSeed: seed, isDark: false))),
"Light peer color should resolve for seed '\(seed)'")
#expect(isUsableRGBA(resolvedRGBA(for: Color(peerSeed: seed, isDark: true))),
"Dark peer color should resolve for seed '\(seed)'")
}
}
@Test func peerColor_isDeterministicForSameSeedAndMode() {
let firstLight = resolvedRGBA(for: Color(peerSeed: "alice", isDark: false))
let secondLight = resolvedRGBA(for: Color(peerSeed: "alice", isDark: false))
let firstDark = resolvedRGBA(for: Color(peerSeed: "alice", isDark: true))
let secondDark = resolvedRGBA(for: Color(peerSeed: "alice", isDark: true))
#expect(firstLight == secondLight)
#expect(firstDark == secondDark)
}
@Test func peerColor_supportsLightAndDarkModes() {
let light = resolvedRGBA(for: Color(peerSeed: "mode-check", isDark: false))
let dark = resolvedRGBA(for: Color(peerSeed: "mode-check", isDark: true))
#expect(isUsableRGBA(light))
#expect(isUsableRGBA(dark))
}
}
private struct RGBA: Equatable {
let red: Double
let green: Double
let blue: Double
let alpha: Double
var values: [Double] {
[red, green, blue, alpha]
}
}
private func resolvedRGBA(for color: Color) -> RGBA? {
#if os(iOS)
let platformColor = UIColor(color)
var red: CGFloat = 0
var green: CGFloat = 0
var blue: CGFloat = 0
var alpha: CGFloat = 0
guard platformColor.getRed(&red, green: &green, blue: &blue, alpha: &alpha) else {
return nil
}
return RGBA(red: Double(red), green: Double(green), blue: Double(blue), alpha: Double(alpha))
#elseif os(macOS)
let platformColor = NSColor(color)
guard let rgbColor = platformColor.usingColorSpace(.deviceRGB) else {
return nil
}
var red: CGFloat = 0
var green: CGFloat = 0
var blue: CGFloat = 0
var alpha: CGFloat = 0
rgbColor.getRed(&red, green: &green, blue: &blue, alpha: &alpha)
return RGBA(red: Double(red), green: Double(green), blue: Double(blue), alpha: Double(alpha))
#else
return nil
#endif
}
private func isUsableRGBA(_ components: RGBA?) -> Bool {
guard let components else { return false }
return components.values.allSatisfy { (0.0...1.0).contains($0) }
}

View File

@ -0,0 +1,87 @@
//
// StringUtilsTests.swift
// bitchatTests
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Testing
@testable import bitchat
struct StringUtilsTests {
// MARK: - DJB2
@Test func djb2_isDeterministic() {
let input = "alice"
#expect(input.djb2() == input.djb2())
}
@Test func djb2_isCaseSensitive() {
let hashes = Set([
"Alice".djb2(),
"alice".djb2(),
"ALICE".djb2()
])
#expect(hashes.count > 1, "DJB2 should not collapse differently cased strings to one value")
}
@Test func djb2_unicodeContentIsDeterministic() {
let unicodeName = "caf\u{00E9}"
#expect(unicodeName.djb2() == unicodeName.djb2())
#expect(unicodeName.djb2() != "cafe".djb2())
#expect("\u{1F44B}".djb2() != "\u{1F44B}!".djb2())
}
@Test func djb2_emptyStringUsesSeedValue() {
#expect("".djb2() == 5381)
}
// MARK: - Nickname suffixes
@Test func splitSuffix_parsesValidSuffix() {
let (base, suffix) = "alice#1a2b".splitSuffix()
#expect(base == "alice")
#expect(suffix == "#1a2b")
}
@Test func splitSuffix_parsesMentionSuffix() {
let (base, suffix) = "@charlie#ffff".splitSuffix()
#expect(base == "charlie")
#expect(suffix == "#ffff")
}
@Test func splitSuffix_returnsNameWhenNoSuffixExists() {
let (base, suffix) = "bob".splitSuffix()
#expect(base == "bob")
#expect(suffix == "")
}
@Test func splitSuffix_rejectsInvalidHex() {
let (base, suffix) = "eve#xyz1".splitSuffix()
#expect(base == "eve#xyz1")
#expect(suffix == "")
}
@Test func splitSuffix_rejectsShortSuffix() {
let (base, suffix) = "a#123".splitSuffix()
#expect(base == "a#123")
#expect(suffix == "")
}
@Test func splitSuffix_rejectsDoubleHashSuffix() {
let (base, suffix) = "test##1234".splitSuffix()
#expect(base == "test##1234", "Double-hash nicknames must remain unsplit")
#expect(suffix == "", "Double-hash nicknames must not produce a suffix")
}
}