diff --git a/bitchat/Info.plist b/bitchat/Info.plist
index fd87f8b57..545045a04 100644
--- a/bitchat/Info.plist
+++ b/bitchat/Info.plist
@@ -46,6 +46,8 @@
UISupportedInterfaceOrientations
UIInterfaceOrientationPortrait
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
UISupportedInterfaceOrientations~ipad
@@ -55,4 +57,4 @@
UIInterfaceOrientationPortraitUpsideDown
-
+
\ No newline at end of file
diff --git a/bitchat/Utils/String+DJB2.swift b/bitchat/Utils/String+DJB2.swift
new file mode 100644
index 000000000..327d7ae5c
--- /dev/null
+++ b/bitchat/Utils/String+DJB2.swift
@@ -0,0 +1,30 @@
+//
+// String+DJB2.swift
+// bitchat
+//
+// This is free and unencumbered software released into the public domain.
+// For more information, see
+//
+
+import Foundation
+
+extension String {
+ /// Computes the DJB2 hash of this string.
+ ///
+ /// DJB2 is a simple, fast non-cryptographic hash function created by Dan Bernstein.
+ /// It uses the magic number 5381 as the initial seed and the formula: `hash = hash * 33 + byte`.
+ ///
+ /// In bitchat, this hash is used to:
+ /// - Generate stable, deterministic peer color assignments from nicknames or public keys
+ /// - Provide consistent hue values for UI elements that need reproducible colors
+ ///
+ /// - Note: This is NOT suitable for cryptographic purposes or security-sensitive operations.
+ /// For cryptographic hashing, use SHA-256 instead.
+ ///
+ /// - Returns: A 64-bit hash value that is deterministic for the same input string
+ func djb2() -> UInt64 {
+ var hash: UInt64 = 5381
+ for b in utf8 { hash = ((hash << 5) &+ hash) &+ UInt64(b) }
+ return hash
+ }
+}
diff --git a/bitchat/Utils/String+Nickname.swift b/bitchat/Utils/String+Nickname.swift
new file mode 100644
index 000000000..08d114d0f
--- /dev/null
+++ b/bitchat/Utils/String+Nickname.swift
@@ -0,0 +1,43 @@
+//
+// String+Nickname.swift
+// bitchat
+//
+// This is free and unencumbered software released into the public domain.
+// For more information, see
+//
+
+import Foundation
+
+extension String {
+ /// Splits a nickname into base and a '#abcd' suffix if present.
+ ///
+ /// In bitchat, peer nicknames can have a 4-character hexadecimal suffix (e.g., "alice#1a2b")
+ /// to differentiate users with the same base nickname. This function parses such nicknames.
+ ///
+ /// The function:
+ /// - Removes any leading '@' character (for mentions)
+ /// - Checks if the string ends with a valid '#' followed by 4 hex digits
+ /// - Returns a tuple of (base, suffix) where suffix includes the '#' character
+ ///
+ /// Examples:
+ /// ```
+ /// "alice#1a2b".splitSuffix() // returns ("alice", "#1a2b")
+ /// "bob".splitSuffix() // returns ("bob", "")
+ /// "@charlie#ffff".splitSuffix() // returns ("charlie", "#ffff")
+ /// "eve#xyz".splitSuffix() // returns ("eve#xyz", "") - invalid hex
+ /// ```
+ ///
+ /// - Returns: A tuple containing the base nickname and the suffix (or empty string if no valid suffix)
+ 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))
+ return (base, suffix)
+ }
+ return (name, "")
+ }
+}
diff --git a/bitchatTests/Utils/ColorPeerTests.swift b/bitchatTests/Utils/ColorPeerTests.swift
new file mode 100644
index 000000000..c265f772a
--- /dev/null
+++ b/bitchatTests/Utils/ColorPeerTests.swift
@@ -0,0 +1,196 @@
+//
+// ColorPeerTests.swift
+// bitchatTests
+//
+// This is free and unencumbered software released into the public domain.
+// For more information, see
+//
+
+import Testing
+import SwiftUI
+@testable import bitchat
+
+struct ColorPeerTests {
+
+ // MARK: - Consistency Tests
+
+ @Test func peerColor_sameSeedProducesSameColor() {
+ let color1 = Color(peerSeed: "alice", isDark: false)
+ let color2 = Color(peerSeed: "alice", isDark: false)
+
+ // Since the cache is static, both should produce the same color
+ // We can't directly compare Color objects, but we can verify they produce consistent hashes
+ #expect(color1.description == color2.description, "Same seed should produce same color")
+ }
+
+ @Test func peerColor_differentSeedsProduceDifferentColors() {
+ let color1 = Color(peerSeed: "alice", isDark: false)
+ let color2 = Color(peerSeed: "bob", isDark: false)
+
+ // Different seeds should produce different colors
+ #expect(color1.description != color2.description, "Different seeds should produce different colors")
+ }
+
+ @Test func peerColor_darkModeDifferent() {
+ let lightColor = Color(peerSeed: "alice", isDark: false)
+ let darkColor = Color(peerSeed: "alice", isDark: true)
+
+ // Same seed but different dark mode should produce different colors
+ #expect(lightColor.description != darkColor.description, "Light and dark mode should produce different colors")
+ }
+
+ // MARK: - Caching Tests
+
+ @Test func peerColor_cacheWorks() {
+ // Generate color twice with same parameters
+ let color1 = Color(peerSeed: "testuser", isDark: false)
+ let color2 = Color(peerSeed: "testuser", isDark: false)
+
+ // Both should be identical (from cache)
+ #expect(color1.description == color2.description, "Cache should return same color object")
+ }
+
+ @Test func peerColor_cacheDistinguishesDarkMode() {
+ // Generate colors for same seed but different dark mode
+ let lightColor1 = Color(peerSeed: "user", isDark: false)
+ let darkColor1 = Color(peerSeed: "user", isDark: true)
+ let lightColor2 = Color(peerSeed: "user", isDark: false)
+ let darkColor2 = Color(peerSeed: "user", isDark: true)
+
+ // Cache should distinguish between light and dark
+ #expect(lightColor1.description == lightColor2.description, "Light colors should match from cache")
+ #expect(darkColor1.description == darkColor2.description, "Dark colors should match from cache")
+ #expect(lightColor1.description != darkColor1.description, "Light and dark should differ")
+ }
+
+ // MARK: - Orange Avoidance Tests
+
+ @Test func peerColor_avoidsOrangeHue() {
+ // Test seeds that might hash to orange-ish hues
+ // Orange is at 30/360 = 0.0833...
+ // Avoidance delta is 0.05, so range is roughly 0.033 to 0.133
+
+ let testSeeds = [
+ "orangish1",
+ "orangish2",
+ "orangish3",
+ "test30",
+ "test35"
+ ]
+
+ for seed in testSeeds {
+ _ = Color(peerSeed: seed, isDark: false)
+ // If the hue was too close to orange, it should have been offset
+ // We can't easily extract the hue from SwiftUI Color, but we can verify it doesn't crash
+ }
+ }
+
+ // MARK: - Hash Distribution Tests
+
+ @Test func peerColor_differentHashBits() {
+ // Test that different parts of the hash affect the color
+ let color1 = Color(peerSeed: "aaa", isDark: false)
+ let color2 = Color(peerSeed: "bbb", isDark: false)
+ let color3 = Color(peerSeed: "ccc", isDark: false)
+
+ // All three should be different
+ #expect(color1.description != color2.description)
+ #expect(color2.description != color3.description)
+ #expect(color1.description != color3.description)
+ }
+
+ // MARK: - Real World Scenarios
+
+ @Test func peerColor_nostrPublicKeys() {
+ // Test with realistic Nostr-like public key hashes
+ let pubkey1 = "npub1abc123def456..."
+ let pubkey2 = "npub1xyz789ghi012..."
+
+ let color1 = Color(peerSeed: pubkey1, isDark: false)
+ let color2 = Color(peerSeed: pubkey2, isDark: false)
+
+ #expect(color1.description != color2.description, "Different pubkeys should produce different colors")
+ }
+
+ @Test func peerColor_shortSeeds() {
+ // Test with short seeds
+ let color1 = Color(peerSeed: "a", isDark: false)
+ let color2 = Color(peerSeed: "b", isDark: false)
+
+ #expect(color1.description != color2.description, "Even short seeds should produce different colors")
+ }
+
+ @Test func peerColor_longSeeds() {
+ // Test with very long seeds
+ let longSeed1 = String(repeating: "a", count: 100)
+ let longSeed2 = String(repeating: "b", count: 100)
+
+ let color1 = Color(peerSeed: longSeed1, isDark: false)
+ let color2 = Color(peerSeed: longSeed2, isDark: false)
+
+ #expect(color1.description != color2.description, "Long seeds should produce different colors")
+ }
+
+ @Test func peerColor_emptyStringSeed() {
+ // Edge case: empty string
+ let color = Color(peerSeed: "", isDark: false)
+
+ // Should not crash and should produce a valid color
+ #expect(color.description.isEmpty == false, "Empty seed should produce valid color")
+ }
+
+ @Test func peerColor_unicodeSeeds() {
+ let color1 = Color(peerSeed: "alice", isDark: false)
+ let color2 = Color(peerSeed: "alicé", isDark: false)
+
+ #expect(color1.description != color2.description, "Unicode differences should affect color")
+ }
+
+ // MARK: - Determinism Tests
+
+ @Test func peerColor_isDeterministic() {
+ // Run multiple times to ensure determinism
+ let seeds = ["alice", "bob", "charlie", "dave", "eve"]
+
+ for seed in seeds {
+ let colors = (0..<10).map { _ in
+ Color(peerSeed: seed, isDark: false).description
+ }
+
+ // All 10 should be identical
+ let uniqueColors = Set(colors)
+ #expect(uniqueColors.count == 1, "Color generation for '\(seed)' should be deterministic")
+ }
+ }
+
+ @Test func peerColor_caseMatters() {
+ let color1 = Color(peerSeed: "Alice", isDark: false)
+ let color2 = Color(peerSeed: "alice", isDark: false)
+ let color3 = Color(peerSeed: "ALICE", isDark: false)
+
+ // All three should be different (case-sensitive)
+ #expect(color1.description != color2.description, "Alice != alice")
+ #expect(color2.description != color3.description, "alice != ALICE")
+ #expect(color1.description != color3.description, "Alice != ALICE")
+ }
+
+ // MARK: - Boundary Value Tests
+
+ @Test func peerColor_numberSeeds() {
+ let color1 = Color(peerSeed: "1", isDark: false)
+ let color2 = Color(peerSeed: "2", isDark: false)
+ let color3 = Color(peerSeed: "99999", isDark: false)
+
+ #expect(color1.description != color2.description)
+ #expect(color2.description != color3.description)
+ }
+
+ @Test func peerColor_specialCharacterSeeds() {
+ let color1 = Color(peerSeed: "user@host", isDark: false)
+ let color2 = Color(peerSeed: "user#1234", isDark: false)
+ let color3 = Color(peerSeed: "user!special", isDark: false)
+
+ #expect(color1.description != color2.description)
+ #expect(color2.description != color3.description)
+ }
+}
diff --git a/bitchatTests/Utils/StringUtilsTests.swift b/bitchatTests/Utils/StringUtilsTests.swift
new file mode 100644
index 000000000..da201dbf1
--- /dev/null
+++ b/bitchatTests/Utils/StringUtilsTests.swift
@@ -0,0 +1,185 @@
+//
+// StringUtilsTests.swift
+// bitchatTests
+//
+// This is free and unencumbered software released into the public domain.
+// For more information, see
+//
+
+import Testing
+import Foundation
+@testable import bitchat
+
+struct StringUtilsTests {
+
+ // MARK: - DJB2 Hash Tests
+
+ @Test func djb2_producesConsistentHash() {
+ let testString = "test"
+ let hash1 = testString.djb2()
+ let hash2 = testString.djb2()
+
+ #expect(hash1 == hash2, "DJB2 should produce consistent hash for same input")
+ }
+
+ @Test func djb2_producesDifferentHashForDifferentStrings() {
+ let hash1 = "alice".djb2()
+ let hash2 = "bob".djb2()
+
+ #expect(hash1 != hash2, "DJB2 should produce different hashes for different inputs")
+ }
+
+ @Test func djb2_emptyStringProducesSeedValue() {
+ let hash = "".djb2()
+
+ // Empty string should return the seed value 5381
+ #expect(hash == 5381, "DJB2 of empty string should be the seed value")
+ }
+
+ @Test func djb2_singleCharacter() {
+ let hash = "a".djb2()
+
+ // hash = ((5381 << 5) + 5381) + 97 (ASCII 'a')
+ // hash = (5381 * 33) + 97 = 177573 + 97 = 177670
+ #expect(hash == 177670, "DJB2 should calculate correctly for single character")
+ }
+
+ @Test func djb2_caseMatters() {
+ let hash1 = "Alice".djb2()
+ let hash2 = "alice".djb2()
+
+ #expect(hash1 != hash2, "DJB2 should be case-sensitive")
+ }
+
+ @Test func djb2_unicodeCharacters() {
+ let hash1 = "hello".djb2()
+ let hash2 = "héllo".djb2()
+
+ #expect(hash1 != hash2, "DJB2 should handle unicode characters")
+ }
+
+ @Test func djb2_longString() {
+ let longString = String(repeating: "a", count: 1000)
+ let hash = longString.djb2()
+
+ #expect(hash > 0, "DJB2 should handle long strings without overflow issues")
+ }
+
+ // MARK: - Nickname Suffix Tests
+
+ @Test func splitSuffix_withValidSuffix() {
+ let (base, suffix) = "alice#1a2b".splitSuffix()
+
+ #expect(base == "alice", "Base should be 'alice'")
+ #expect(suffix == "#1a2b", "Suffix should be '#1a2b'")
+ }
+
+ @Test func splitSuffix_withoutSuffix() {
+ let (base, suffix) = "bob".splitSuffix()
+
+ #expect(base == "bob", "Base should be 'bob'")
+ #expect(suffix == "", "Suffix should be empty")
+ }
+
+ @Test func splitSuffix_withMentionSymbol() {
+ let (base, suffix) = "@charlie#ffff".splitSuffix()
+
+ #expect(base == "charlie", "Base should be 'charlie' with @ removed")
+ #expect(suffix == "#ffff", "Suffix should be '#ffff'")
+ }
+
+ @Test func splitSuffix_invalidHexInSuffix() {
+ let (base, suffix) = "eve#xyz1".splitSuffix()
+
+ #expect(base == "eve#xyz1", "Should return full string as base when suffix is invalid")
+ #expect(suffix == "", "Suffix should be empty for invalid hex")
+ }
+
+ @Test func splitSuffix_uppercaseHex() {
+ let (base, suffix) = "frank#ABCD".splitSuffix()
+
+ #expect(base == "frank", "Base should be 'frank'")
+ #expect(suffix == "#ABCD", "Suffix should handle uppercase hex")
+ }
+
+ @Test func splitSuffix_mixedCaseHex() {
+ let (base, suffix) = "grace#1A2b".splitSuffix()
+
+ #expect(base == "grace", "Base should be 'grace'")
+ #expect(suffix == "#1A2b", "Suffix should handle mixed case hex")
+ }
+
+ @Test func splitSuffix_tooShortForSuffix() {
+ let (base, suffix) = "dan".splitSuffix()
+
+ #expect(base == "dan", "Base should be 'dan'")
+ #expect(suffix == "", "Suffix should be empty for strings too short")
+ }
+
+ @Test func splitSuffix_exactlyFiveChars() {
+ let (base, suffix) = "#1234".splitSuffix()
+
+ #expect(base == "", "Base should be empty")
+ #expect(suffix == "#1234", "Suffix should be '#1234'")
+ }
+
+ @Test func splitSuffix_hashButNoSuffix() {
+ let (base, suffix) = "user#".splitSuffix()
+
+ #expect(base == "user#", "Base should include the # when suffix is incomplete")
+ #expect(suffix == "", "Suffix should be empty")
+ }
+
+ @Test func splitSuffix_multipleMentionSymbols() {
+ let (base, suffix) = "@@user#1a2b".splitSuffix()
+
+ // All @ symbols should be removed
+ #expect(base == "user", "All @ symbols should be removed")
+ #expect(suffix == "#1a2b", "Suffix should be '#1a2b'")
+ }
+
+ @Test func splitSuffix_zeroesInHex() {
+ let (base, suffix) = "zero#0000".splitSuffix()
+
+ #expect(base == "zero", "Base should be 'zero'")
+ #expect(suffix == "#0000", "Suffix should handle all zeros")
+ }
+
+ @Test func splitSuffix_onlyThreeHexDigits() {
+ let (base, suffix) = "short#123".splitSuffix()
+
+ #expect(base == "short#123", "Should not recognize 3-digit suffix")
+ #expect(suffix == "", "Suffix should be empty for wrong length")
+ }
+
+ @Test func splitSuffix_fiveHexDigits() {
+ let (base, suffix) = "long#12345".splitSuffix()
+
+ #expect(base == "long#12345", "Should not recognize 5-digit suffix")
+ #expect(suffix == "", "Suffix should be empty for wrong length")
+ }
+
+ @Test func splitSuffix_multipleHashSymbols() {
+ let (base, suffix) = "test##1234".splitSuffix()
+
+ // Should only look at the last 5 characters
+ #expect(base == "test##1234", "Should not recognize suffix with double hash")
+ #expect(suffix == "", "Suffix should be empty")
+ }
+
+ @Test func splitSuffix_emptyString() {
+ let (base, suffix) = "".splitSuffix()
+
+ #expect(base == "", "Base should be empty")
+ #expect(suffix == "", "Suffix should be empty")
+ }
+
+ @Test func splitSuffix_realWorldExample() {
+ let (base1, suffix1) = "alice#a1b2".splitSuffix()
+ let (base2, suffix2) = "alice#c3d4".splitSuffix()
+
+ #expect(base1 == base2, "Base nicknames should match")
+ #expect(suffix1 != suffix2, "Suffixes should differ")
+ #expect(base1 == "alice", "Base should be 'alice'")
+ }
+}