Merge remote-tracking branch 'origin/main' into codex/nostr-double-ratchet
# Conflicts: # .gitignore # app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt # app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt # app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt
9
.gitignore
vendored
@ -7,6 +7,7 @@ build/
|
||||
!*/build/intermediates/
|
||||
local.properties
|
||||
.gradle/
|
||||
.kotlin/
|
||||
captures/
|
||||
.externalNativeBuild/
|
||||
debug_keystore/
|
||||
@ -40,6 +41,11 @@ dependency-reduced-pom.xml
|
||||
# Linters
|
||||
.lint/
|
||||
|
||||
# Python test tooling
|
||||
**/__pycache__/
|
||||
*.py[cod]
|
||||
release-gate-results/
|
||||
|
||||
# Other
|
||||
*.log
|
||||
.cxx/
|
||||
@ -66,3 +72,6 @@ tools/arti-build/target/
|
||||
|
||||
# Generated from the pinned nostr-double-ratchet source submodule.
|
||||
app/src/main/jniLibs/*/libndr_ffi.so
|
||||
|
||||
# JVM heap dumps (a Gradle daemon OOM drops these in the repo root)
|
||||
*.hprof
|
||||
|
||||
6
app/src/main/assets/design-icons/README.md
Normal file
@ -0,0 +1,6 @@
|
||||
# Design-spec icons
|
||||
|
||||
These standalone SVGs were extracted from the supplied 393 px Figma screen exports. The matching
|
||||
Android vector resources in `res/drawable/ic_spec_*.xml` are the runtime copies used by Compose.
|
||||
Paths and stroke weights remain faithful to the exports; UI tint and opacity are applied at the
|
||||
call site so selected and disabled states remain theme-aware.
|
||||
3
app/src/main/assets/design-icons/bookmark-filled.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3 15L8 11.267L13 15V2.4C13 2.029 12.856 1.673 12.601 1.41C12.345 1.147 11.998 1 11.636 1H4.364C4.002 1 3.655 1.147 3.399 1.41C3.144 1.673 3 2.029 3 2.4V15Z" fill="currentColor" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 370 B |
3
app/src/main/assets/design-icons/bookmark-outline.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3 15L8 11.267L13 15V2.4C13 2.029 12.856 1.673 12.601 1.41C12.345 1.147 11.998 1 11.636 1H4.364C4.002 1 3.655 1.147 3.399 1.41C3.144 1.673 3 2.029 3 2.4V15Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 362 B |
6
app/src/main/assets/design-icons/chat-bubbles.svg
Normal file
@ -0,0 +1,6 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g transform="translate(-16 -370)" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M28.5 370.625H18.5C18.0027 370.625 17.5258 370.823 17.1742 371.174C16.8225 371.526 16.625 372.003 16.625 372.5V378.75C16.625 379.247 16.8225 379.724 17.1742 380.076C17.5258 380.427 18.0027 380.625 18.5 380.625H20.375V384.375L24.125 380.625H28.5C28.9973 380.625 29.4742 380.427 29.8258 380.076C30.1775 379.724 30.375 379.247 30.375 378.75V372.5C30.375 372.003 30.1775 371.526 29.8258 371.174C29.4742 370.823 28.9973 370.625 28.5 370.625Z"/>
|
||||
<path d="M24.125 385.625H27.875L31.625 389.375V385.625H33.5C33.9973 385.625 34.4742 385.427 34.8258 385.076C35.1775 384.724 35.375 384.247 35.375 383.75V377.5C35.375 377.003 35.1775 376.526 34.8258 376.174C34.4742 375.823 33.9973 375.625 33.5 375.625H32.875"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 955 B |
6
app/src/main/assets/design-icons/command.svg
Normal file
@ -0,0 +1,6 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g transform="translate(-16 -407)" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M26.625 422.875H35.375"/>
|
||||
<path d="M16.625 407.875L24.125 415.375L16.625 422.875"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 338 B |
6
app/src/main/assets/design-icons/eye-off.svg
Normal file
@ -0,0 +1,6 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g transform="translate(-16 -682)" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M16.625 692C16.625 692 20.375 685.125 26 685.125C31.625 685.125 35.375 692 35.375 692C35.375 692 31.625 698.875 26 698.875C20.375 698.875 16.625 692 16.625 692Z"/>
|
||||
<path d="M22.25 692C22.25 689.929 23.9288 688.25 26 688.25M29.75 692C29.75 694.071 28.0712 695.75 26 695.75M17.25 700.75L34.75 683.25"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 555 B |
7
app/src/main/assets/design-icons/globe.svg
Normal file
@ -0,0 +1,7 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g transform="translate(-16 -278)" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M25.9999 297.375C28.2187 297.375 30.0174 293.178 30.0174 288C30.0174 282.822 28.2187 278.625 25.9999 278.625C23.7811 278.625 21.9824 282.822 21.9824 288C21.9824 293.178 23.7811 297.375 25.9999 297.375Z"/>
|
||||
<path d="M16.625 288H35.375"/>
|
||||
<path d="M26 297.375C31.1777 297.375 35.375 293.178 35.375 288C35.375 282.822 31.1777 278.625 26 278.625C20.8223 278.625 16.625 282.822 16.625 288C16.625 293.178 20.8223 297.375 26 297.375Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 687 B |
7
app/src/main/assets/design-icons/lock.svg
Normal file
@ -0,0 +1,7 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g transform="translate(-16 -310)" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M32.25 329.375H19.75C18.715 329.375 17.875 328.535 17.875 327.5V321.25C17.875 320.215 18.715 319.375 19.75 319.375H32.25C33.285 319.375 34.125 320.215 34.125 321.25V327.5C34.125 328.535 33.285 329.375 32.25 329.375Z"/>
|
||||
<path d="M21.625 316.875V315C21.625 312.584 23.5838 310.625 26 310.625C28.4162 310.625 30.375 312.584 30.375 315V316.875"/>
|
||||
<path d="M26 326.25C27.0355 326.25 27.875 325.411 27.875 324.375C27.875 323.339 27.0355 322.5 26 322.5C24.9645 322.5 24.125 323.339 24.125 324.375C24.125 325.411 24.9645 326.25 26 326.25Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 792 B |
6
app/src/main/assets/design-icons/mention.svg
Normal file
@ -0,0 +1,6 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g transform="translate(-16 -582)" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M29.5236 587.888V594.4C29.5236 596.41 32.5761 596.856 34.1749 594.139C35.5299 591.84 35.1974 588.334 33.5049 586.025C31.0149 582.629 25.2574 581.359 21.0736 584.166C17.2311 586.746 15.8624 591.968 17.9861 596.188C20.0874 600.364 25.0199 602.386 29.4861 600.876"/>
|
||||
<path d="M25.8485 595.785C27.8698 595.785 29.5085 594.032 29.5085 591.87C29.5085 589.708 27.8698 587.955 25.8485 587.955C23.8271 587.955 22.1885 589.708 22.1885 591.87C22.1885 594.032 23.8271 595.785 25.8485 595.785Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 738 B |
5
app/src/main/assets/design-icons/on-location-person.svg
Normal file
@ -0,0 +1,5 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M2 15.5H14" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M8 12.5V8.5" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<circle cx="8" cy="4.5" r="4" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 395 B |
3
app/src/main/assets/design-icons/panic.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path transform="translate(-16 -786)" d="M17.875 805.375H22.875M20.375 786.625V789.125M20.375 789.125L27.875 796.625M16.625 790.375L22.875 796.625M30.375 789.125L35.375 794.125M25.375 789.125L32.875 796.625M35.375 789.125H16.625V796.625H35.375V789.125ZM20.375 805.375V799.125M29.125 805.375H34.125M31.625 786.625V789.125M31.625 805.375V799.125" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 541 B |
6
app/src/main/assets/design-icons/people.svg
Normal file
@ -0,0 +1,6 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g transform="translate(-16 -314)" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M28.2187 328.116L25.14 327.241C25.0239 327.208 24.9198 327.142 24.8404 327.051C24.761 326.96 24.7096 326.848 24.6925 326.729L24.4338 324.907C25.0869 324.609 25.6407 324.129 26.0291 323.525C26.4176 322.921 26.6244 322.218 26.625 321.5V319.782C26.6402 318.788 26.2704 317.826 25.593 317.098C24.9156 316.37 23.9829 315.932 22.99 315.875C22.488 315.86 21.9879 315.945 21.5196 316.127C21.0513 316.308 20.6242 316.582 20.2637 316.932C19.9032 317.282 19.6166 317.7 19.421 318.163C19.2254 318.625 19.1248 319.123 19.125 319.625V321.5C19.1256 322.218 19.3324 322.921 19.7209 323.525C20.1093 324.129 20.6631 324.609 21.3162 324.907L21.0575 326.724C21.0404 326.843 20.989 326.955 20.9096 327.046C20.8302 327.137 20.7261 327.203 20.61 327.236L17.5313 328.111C17.2702 328.186 17.0406 328.343 16.8771 328.56C16.7136 328.777 16.6251 329.041 16.625 329.312V332.125H29.125V329.317C29.1249 329.046 29.0364 328.782 28.8729 328.565C28.7094 328.348 28.4798 328.191 28.2187 328.116Z"/>
|
||||
<path d="M31.625 332.125H35.375V328.101C35.375 327.823 35.2819 327.552 35.1104 327.332C34.939 327.113 34.6991 326.956 34.4288 326.889L30.7825 325.977C30.6618 325.947 30.5528 325.882 30.4695 325.789C30.3863 325.697 30.3324 325.582 30.315 325.459L30.0587 323.657C30.7119 323.359 31.2657 322.879 31.6541 322.275C32.0426 321.671 32.2494 320.968 32.25 320.25V318.532C32.2652 317.538 31.8954 316.576 31.218 315.848C30.5406 315.12 29.6079 314.682 28.615 314.625C27.9181 314.603 27.229 314.777 26.625 315.125"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
6
app/src/main/assets/design-icons/person.svg
Normal file
@ -0,0 +1,6 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g transform="translate(-16 -242)" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M27.725 252.271L28.1875 255.125H31.48C32.3075 255.125 33.0375 255.669 33.2763 256.461L34.75 261.375H17.25L18.7237 256.461C18.9612 255.669 19.6912 255.125 20.52 255.125H23.8125L24.275 252.271"/>
|
||||
<path d="M30.375 247C30.375 244.584 28.4162 242.625 26 242.625C23.5838 242.625 21.625 244.584 21.625 247V248.25C21.625 250.666 23.5838 252.625 26 252.625C28.4162 252.625 30.375 250.666 30.375 248.25V247Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 655 B |
6
app/src/main/assets/design-icons/range.svg
Normal file
@ -0,0 +1,6 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g transform="translate(-16 -378)" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M25.375 386.125C26.0654 386.125 26.625 385.565 26.625 384.875C26.625 384.185 26.0654 383.625 25.375 383.625C24.6846 383.625 24.125 384.185 24.125 384.875C24.125 385.565 24.6846 386.125 25.375 386.125Z"/>
|
||||
<path d="M25.375 397.375V386.125M21.625 397.375H29.125M28.9102 388.41C29.8475 387.472 30.3741 386.201 30.3741 384.875C30.3741 383.549 29.8475 382.278 28.9102 381.34M31.5625 391.062C32.3751 390.25 33.0197 389.285 33.4595 388.224C33.8993 387.162 34.1256 386.024 34.1256 384.875C34.1256 383.726 33.8993 382.588 33.4595 381.526C33.0197 380.465 32.3751 379.5 31.5625 378.688M21.8399 388.41C20.9026 387.472 20.376 386.201 20.376 384.875C20.376 383.549 20.9026 382.278 21.8399 381.34M19.1876 391.062C18.375 390.25 17.7304 389.285 17.2907 388.224C16.8509 387.162 16.6245 386.024 16.6245 384.875C16.6245 383.726 16.8509 382.588 17.2907 381.526C17.7304 380.465 18.375 379.5 19.1876 378.688"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
3
app/src/main/assets/design-icons/shuffle.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path transform="translate(-16 -734)" d="M16.625 749.625H16.88C18.3088 749.625 19.6688 749.014 20.6175 747.946L27.6338 740.053C28.5825 738.985 29.9425 738.374 31.3712 738.374H35.375M31.625 734.625L35.375 738.375L31.625 742.125M26.3477 746.5L27.6339 747.946C28.5827 749.014 29.9427 749.625 31.3714 749.625H35.3752M16.625 738.375H16.88C18.3088 738.375 19.6688 738.986 20.6175 740.054L21.9025 741.5M31.625 753.375L35.375 749.625L31.625 745.875" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 638 B |
3
app/src/main/assets/design-icons/star-filled.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
|
||||
<path transform="translate(-16 -446)" d="M26 446.925L28.1637 453.836H35.375L29.645 458.143L31.8975 465.075L26 460.79L20.1025 465.075L22.355 458.143L16.625 453.836H23.8363L26 446.925Z" fill="currentColor" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 388 B |
3
app/src/main/assets/design-icons/star.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path transform="translate(-16 -446)" d="M26 446.925L28.1637 453.836H35.375L29.645 458.143L31.8975 465.075L26 460.79L20.1025 465.075L22.355 458.143L16.625 453.836H23.8363L26 446.925Z" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 380 B |
5
app/src/main/assets/design-icons/teleport.svg
Normal file
@ -0,0 +1,5 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="8" cy="8" r="7.5" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="3 3"/>
|
||||
<circle cx="5" cy="6" r="1" fill="currentColor"/><circle cx="11" cy="6" r="1" fill="currentColor"/>
|
||||
<path d="M5.5 10.5H10.5" stroke="currentColor" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 405 B |
3
app/src/main/assets/design-icons/waveform.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M0 5H2.5V12.5H0ZM2.5 2.5H5V15H2.5ZM5 0H7.5V15H5ZM7.5 0H10V17.5H7.5ZM10 0H12.5V17.5H10ZM12.5 0H15V20H12.5ZM15 2.5H17.5V15H15ZM17.5 5H20V12.5H17.5Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 271 B |
8
app/src/main/assets/design-icons/wifi-off.svg
Normal file
@ -0,0 +1,8 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g transform="translate(-16 -242)" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21.1387 252.139C22.4278 250.851 24.1757 250.127 25.998 250.127C27.8204 250.127 29.5683 250.851 30.8574 252.139"/>
|
||||
<path d="M27.25 255.601C27.4831 255.81 27.6603 256.073 27.7656 256.368C27.8708 256.662 27.9008 256.978 27.8527 257.287C27.8046 257.596 27.68 257.888 27.4902 258.137C27.3004 258.385 27.0514 258.582 26.766 258.71C26.4806 258.838 26.1677 258.892 25.856 258.868C25.5442 258.844 25.2433 258.743 24.9809 258.573C24.7184 258.403 24.5026 258.17 24.3531 257.895C24.2036 257.62 24.1252 257.313 24.125 257"/>
|
||||
<path d="M17.6025 248.604C19.8294 246.378 22.8493 245.127 25.9982 245.127C29.147 245.127 32.1669 246.378 34.3938 248.604"/>
|
||||
<path d="M34.125 243.875L19.125 258.875"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 942 B |
@ -21,6 +21,7 @@ import androidx.lifecycle.repeatOnLifecycle
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import com.bitchat.android.mesh.BluetoothMeshService
|
||||
import com.bitchat.android.mesh.MeshService
|
||||
import com.bitchat.android.geohash.LocationChannelManager
|
||||
import com.bitchat.android.onboarding.BluetoothCheckScreen
|
||||
import com.bitchat.android.onboarding.BluetoothStatus
|
||||
import com.bitchat.android.onboarding.BluetoothStatusManager
|
||||
@ -743,6 +744,9 @@ class MainActivity : OrientationAwareActivity() {
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
// Revoke stale live-location work before any resumed UI can use cached channels.
|
||||
LocationChannelManager.getInstance(applicationContext).syncPermissionState()
|
||||
|
||||
// Check Bluetooth and Location status on resume and handle accordingly
|
||||
if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) {
|
||||
// Reattach mesh delegate to new ChatViewModel instance after Activity recreation
|
||||
@ -817,7 +821,7 @@ class MainActivity : OrientationAwareActivity() {
|
||||
val geohash = intent.getStringExtra(com.bitchat.android.ui.NotificationManager.EXTRA_GEOHASH)
|
||||
|
||||
if (geohash != null) {
|
||||
Log.d("MainActivity", "Opening geohash chat #$geohash from notification")
|
||||
Log.d("MainActivity", "Opening geohash chat from notification")
|
||||
|
||||
// Switch to the geohash channel - create appropriate geohash channel level
|
||||
val level = when (geohash.length) {
|
||||
|
||||
@ -1,8 +1,11 @@
|
||||
package com.bitchat.android.core.ui.component.button
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
@ -12,10 +15,15 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.bitchat.android.core.ui.icon.BitChatIcon
|
||||
import com.bitchat.android.ui.rememberPressScale
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
@ -30,6 +38,7 @@ fun BitChatBrandButton(
|
||||
contentDescription: String,
|
||||
modifier: Modifier = Modifier,
|
||||
tint: Color = MaterialTheme.colorScheme.primary,
|
||||
iconSize: Dp = 22.dp,
|
||||
) {
|
||||
var tapCount by remember { mutableIntStateOf(0) }
|
||||
var resetJob by remember { mutableStateOf<Job?>(null) }
|
||||
@ -37,33 +46,47 @@ fun BitChatBrandButton(
|
||||
val currentOnClick by rememberUpdatedState(onClick)
|
||||
val currentOnTripleClick by rememberUpdatedState(onTripleClick)
|
||||
|
||||
IconButton(
|
||||
onClick = {
|
||||
tapCount += 1
|
||||
resetJob?.cancel()
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
val pressScale = rememberPressScale(interactionSource)
|
||||
|
||||
if (tapCount == 3) {
|
||||
tapCount = 0
|
||||
resetJob = null
|
||||
currentOnTripleClick()
|
||||
} else {
|
||||
resetJob = coroutineScope.launch {
|
||||
delay(MultiClickThreshold)
|
||||
if (tapCount == 1) {
|
||||
currentOnClick()
|
||||
}
|
||||
// A plain Box rather than an IconButton: IconButton insists on drawing a ripple, which was the
|
||||
// only press background left in the header once every other control moved to scale-only
|
||||
// feedback.
|
||||
Box(
|
||||
modifier = modifier
|
||||
.clip(CircleShape)
|
||||
.clickable(
|
||||
interactionSource = interactionSource,
|
||||
indication = null,
|
||||
onClickLabel = contentDescription
|
||||
) {
|
||||
tapCount += 1
|
||||
resetJob?.cancel()
|
||||
|
||||
if (tapCount == 3) {
|
||||
tapCount = 0
|
||||
resetJob = null
|
||||
currentOnTripleClick()
|
||||
} else {
|
||||
resetJob = coroutineScope.launch {
|
||||
delay(MultiClickThreshold)
|
||||
if (tapCount == 1) {
|
||||
currentOnClick()
|
||||
}
|
||||
tapCount = 0
|
||||
resetJob = null
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = modifier,
|
||||
},
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = BitChatIcon,
|
||||
contentDescription = contentDescription,
|
||||
tint = tint,
|
||||
modifier = Modifier.size(16.dp),
|
||||
modifier = Modifier
|
||||
.size(iconSize)
|
||||
.scale(pressScale),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,34 +1,38 @@
|
||||
package com.bitchat.android.core.ui.component.button
|
||||
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.IconButtonDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.bitchat.android.R
|
||||
|
||||
@Composable
|
||||
fun CloseButton(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier.Companion
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
IconButton(
|
||||
onClick = onClick,
|
||||
modifier = modifier
|
||||
.size(32.dp),
|
||||
// 44.dp to match every other tap target in the app's chrome.
|
||||
modifier = modifier.size(44.dp),
|
||||
colors = IconButtonDefaults.iconButtonColors(
|
||||
contentColor = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f),
|
||||
containerColor = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.1f)
|
||||
contentColor = colorScheme.primary,
|
||||
containerColor = Color.Transparent
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
contentDescription = "Close",
|
||||
modifier = Modifier.Companion.size(18.dp)
|
||||
contentDescription = stringResource(R.string.close_plain),
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,8 +9,26 @@ import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.SheetState
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Dismisses the enclosing [BitchatBottomSheet], playing the slide-down first.
|
||||
*
|
||||
* `ModalBottomSheet` only animates itself out when *it* initiates the dismissal — a swipe or a tap
|
||||
* on the scrim. Anything that closes a sheet programmatically (a close button, picking an item from
|
||||
* a list) previously flipped the caller's `isPresented` flag straight to false, which yanks the
|
||||
* composable out of the tree and makes the sheet vanish instantly.
|
||||
*
|
||||
* Anything inside a sheet that wants to close it should prefer this over calling its own
|
||||
* `onDismiss` directly.
|
||||
*/
|
||||
val LocalSheetDismiss = staticCompositionLocalOf<(() -> Unit)?> { null }
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@ -20,6 +38,20 @@ fun BitchatBottomSheet(
|
||||
onDismissRequest: () -> Unit,
|
||||
content: @Composable (ColumnScope.() -> Unit),
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
// Runs the hide animation to completion, then tells the caller to drop the sheet. `hide()`
|
||||
// throws if the sheet is already on its way out (two rapid taps on a close button), which is
|
||||
// benign — the dismissal still has to go through.
|
||||
val animatedDismiss: () -> Unit = remember(sheetState, onDismissRequest) {
|
||||
{
|
||||
scope.launch {
|
||||
runCatching { sheetState.hide() }
|
||||
onDismissRequest()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ModalBottomSheet(
|
||||
modifier = modifier.statusBarsPadding(),
|
||||
onDismissRequest = onDismissRequest,
|
||||
@ -27,6 +59,9 @@ fun BitchatBottomSheet(
|
||||
dragHandle = null,
|
||||
shape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp),
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
) {
|
||||
CompositionLocalProvider(LocalSheetDismiss provides animatedDismiss) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -10,9 +10,10 @@ import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import com.bitchat.android.ui.theme.BitchatFontFamily
|
||||
import com.bitchat.android.core.ui.component.button.CloseButton
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@ -30,8 +31,9 @@ fun BitchatSheetTopBar(
|
||||
navigationIcon = { navigationIcon?.invoke() },
|
||||
actions = {
|
||||
actions()
|
||||
val dismiss = LocalSheetDismiss.current
|
||||
CloseButton(
|
||||
onClick = onClose,
|
||||
onClick = { dismiss?.invoke() ?: onClose() },
|
||||
modifier = Modifier.padding(horizontal = 16.dp)
|
||||
)
|
||||
},
|
||||
@ -59,8 +61,9 @@ fun BitchatSheetCenterTopBar(
|
||||
navigationIcon = { navigationIcon?.invoke() },
|
||||
actions = {
|
||||
actions()
|
||||
val dismiss = LocalSheetDismiss.current
|
||||
CloseButton(
|
||||
onClick = onClose,
|
||||
onClick = { dismiss?.invoke() ?: onClose() },
|
||||
modifier = Modifier.padding(horizontal = 16.dp)
|
||||
)
|
||||
},
|
||||
@ -80,7 +83,7 @@ fun BitchatSheetTitle(text: String) {
|
||||
text = text,
|
||||
style = MaterialTheme.typography.titleMedium.copy(
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontFamily = FontFamily.Monospace
|
||||
fontFamily = BitchatFontFamily
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@ -14,27 +14,53 @@ class AndroidGeocoderProvider(context: Context) : GeocoderProvider {
|
||||
private val geocoder = Geocoder(context, Locale.getDefault())
|
||||
private val TAG = "AndroidGeocoderProvider"
|
||||
|
||||
override suspend fun getFromLocation(latitude: Double, longitude: Double, maxResults: Int): List<Address> {
|
||||
override suspend fun getFromLocation(
|
||||
latitude: Double,
|
||||
longitude: Double,
|
||||
maxResults: Int,
|
||||
liveLocationToken: Long?
|
||||
): List<Address> {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
suspendCancellableCoroutine { cont ->
|
||||
try {
|
||||
geocoder.getFromLocation(
|
||||
latitude,
|
||||
longitude,
|
||||
maxResults,
|
||||
object : Geocoder.GeocodeListener {
|
||||
override fun onGeocode(addresses: MutableList<Address>) {
|
||||
if (cont.isActive) cont.resume(addresses)
|
||||
}
|
||||
val startRequest = {
|
||||
geocoder.getFromLocation(
|
||||
latitude,
|
||||
longitude,
|
||||
maxResults,
|
||||
object : Geocoder.GeocodeListener {
|
||||
override fun onGeocode(addresses: MutableList<Address>) {
|
||||
if (cont.isActive) {
|
||||
val result = if (liveLocationToken == null ||
|
||||
LiveLocationPrivacyGate.accepts(liveLocationToken)
|
||||
) {
|
||||
addresses
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
cont.resume(result)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onError(errorMessage: String?) {
|
||||
if (cont.isActive) {
|
||||
Log.e(TAG, "Geocode error: $errorMessage")
|
||||
cont.resume(emptyList())
|
||||
override fun onError(errorMessage: String?) {
|
||||
if (cont.isActive) {
|
||||
Log.e(TAG, "Geocode error")
|
||||
cont.resume(emptyList())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
val started = if (liveLocationToken == null) {
|
||||
startRequest()
|
||||
true
|
||||
} else {
|
||||
LiveLocationPrivacyGate.runIfAllowed(
|
||||
liveLocationToken,
|
||||
startRequest
|
||||
)
|
||||
}
|
||||
if (!started && cont.isActive) cont.resume(emptyList())
|
||||
} catch (e: Exception) {
|
||||
if (cont.isActive) cont.resumeWithException(e)
|
||||
}
|
||||
@ -42,9 +68,27 @@ class AndroidGeocoderProvider(context: Context) : GeocoderProvider {
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
try {
|
||||
geocoder.getFromLocation(latitude, longitude, maxResults) ?: emptyList()
|
||||
if (liveLocationToken != null &&
|
||||
!LiveLocationPrivacyGate.accepts(liveLocationToken)
|
||||
) return emptyList()
|
||||
|
||||
// This legacy API blocks and cannot be cancelled. Never hold the privacy
|
||||
// gate's read lock across the call: revocation must remain immediate.
|
||||
val addresses = geocoder.getFromLocation(
|
||||
latitude,
|
||||
longitude,
|
||||
maxResults
|
||||
) ?: emptyList()
|
||||
|
||||
if (liveLocationToken == null ||
|
||||
LiveLocationPrivacyGate.accepts(liveLocationToken)
|
||||
) {
|
||||
addresses
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Geocode failed", e)
|
||||
Log.e(TAG, "Geocode failed")
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,8 +9,9 @@ import android.os.Looper
|
||||
import android.util.Log
|
||||
import androidx.core.app.ActivityCompat
|
||||
import com.google.android.gms.location.*
|
||||
import com.google.android.gms.tasks.CancellationTokenSource
|
||||
|
||||
class FusedLocationProvider(private val context: Context) : LocationProvider {
|
||||
internal class FusedLocationProvider(private val context: Context) : LocationProvider {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "FusedLocationProvider"
|
||||
@ -20,10 +21,13 @@ class FusedLocationProvider(private val context: Context) : LocationProvider {
|
||||
|
||||
// Map to keep track of callbacks to remove them later
|
||||
private val activeCallbacks = mutableMapOf<(Location) -> Unit, LocationCallback>()
|
||||
private val activeCurrentLocationRequests = mutableSetOf<CancellationTokenSource>()
|
||||
|
||||
private fun hasLocationPermission(): Boolean {
|
||||
return ActivityCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED ||
|
||||
return LiveLocationPrivacyGate.isEnabled &&
|
||||
(ActivityCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED ||
|
||||
ActivityCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
|
||||
)
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
@ -36,14 +40,14 @@ class FusedLocationProvider(private val context: Context) : LocationProvider {
|
||||
try {
|
||||
fusedLocationClient.lastLocation
|
||||
.addOnSuccessListener { location ->
|
||||
callback(location)
|
||||
callback(location.takeIf { LiveLocationPrivacyGate.isEnabled })
|
||||
}
|
||||
.addOnFailureListener { e ->
|
||||
Log.e(TAG, "Error getting last known fused location: ${e.message}")
|
||||
Log.e(TAG, "Error getting last-known fused location")
|
||||
callback(null)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Exception getting last known fused location: ${e.message}")
|
||||
Log.e(TAG, "Exception getting last-known fused location")
|
||||
callback(null)
|
||||
}
|
||||
}
|
||||
@ -60,17 +64,27 @@ class FusedLocationProvider(private val context: Context) : LocationProvider {
|
||||
.setPriority(Priority.PRIORITY_HIGH_ACCURACY)
|
||||
.setDurationMillis(30000)
|
||||
.build()
|
||||
val cancellation = CancellationTokenSource()
|
||||
|
||||
fusedLocationClient.getCurrentLocation(request, null)
|
||||
synchronized(activeCurrentLocationRequests) {
|
||||
activeCurrentLocationRequests.add(cancellation)
|
||||
}
|
||||
|
||||
fusedLocationClient.getCurrentLocation(request, cancellation.token)
|
||||
.addOnSuccessListener { location ->
|
||||
callback(location)
|
||||
callback(location.takeIf { LiveLocationPrivacyGate.isEnabled })
|
||||
}
|
||||
.addOnFailureListener { e ->
|
||||
Log.e(TAG, "Error getting fresh fused location: ${e.message}")
|
||||
Log.e(TAG, "Error getting fresh fused location")
|
||||
callback(null)
|
||||
}
|
||||
.addOnCompleteListener {
|
||||
synchronized(activeCurrentLocationRequests) {
|
||||
activeCurrentLocationRequests.remove(cancellation)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Exception getting fresh fused location: ${e.message}")
|
||||
Log.e(TAG, "Exception getting fresh fused location")
|
||||
callback(null)
|
||||
}
|
||||
}
|
||||
@ -91,7 +105,9 @@ class FusedLocationProvider(private val context: Context) : LocationProvider {
|
||||
|
||||
val locationCallback = object : LocationCallback() {
|
||||
override fun onLocationResult(result: LocationResult) {
|
||||
result.lastLocation?.let { callback(it) }
|
||||
if (LiveLocationPrivacyGate.isEnabled) {
|
||||
result.lastLocation?.let { callback(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -107,7 +123,7 @@ class FusedLocationProvider(private val context: Context) : LocationProvider {
|
||||
Log.d(TAG, "Registered fused updates")
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error requesting fused updates: ${e.message}")
|
||||
Log.e(TAG, "Error requesting fused updates")
|
||||
}
|
||||
}
|
||||
|
||||
@ -122,21 +138,25 @@ class FusedLocationProvider(private val context: Context) : LocationProvider {
|
||||
Log.d(TAG, "Removed fused updates")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error removing fused updates: ${e.message}")
|
||||
Log.e(TAG, "Error removing fused updates")
|
||||
}
|
||||
}
|
||||
|
||||
override fun cancel() {
|
||||
try {
|
||||
synchronized(activeCallbacks) {
|
||||
for ((callback, locationCallback) in activeCallbacks) {
|
||||
for ((_, locationCallback) in activeCallbacks) {
|
||||
fusedLocationClient.removeLocationUpdates(locationCallback)
|
||||
}
|
||||
activeCallbacks.clear()
|
||||
}
|
||||
synchronized(activeCurrentLocationRequests) {
|
||||
activeCurrentLocationRequests.forEach { it.cancel() }
|
||||
activeCurrentLocationRequests.clear()
|
||||
}
|
||||
Log.d(TAG, "Cancelled all fused updates")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error cancelling fused provider: ${e.message}")
|
||||
Log.e(TAG, "Error cancelling fused provider")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,5 +9,10 @@ interface GeocoderProvider {
|
||||
/**
|
||||
* Get a list of Address objects from latitude and longitude.
|
||||
*/
|
||||
suspend fun getFromLocation(latitude: Double, longitude: Double, maxResults: Int): List<Address>
|
||||
suspend fun getFromLocation(
|
||||
latitude: Double,
|
||||
longitude: Double,
|
||||
maxResults: Int,
|
||||
liveLocationToken: Long? = null
|
||||
): List<Address>
|
||||
}
|
||||
|
||||
@ -112,7 +112,7 @@ class GeohashBookmarksStore private constructor(private val context: Context) {
|
||||
_bookmarks.value = ordered
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to load bookmarks: ${e.message}")
|
||||
Log.e(TAG, "Failed to load bookmarks")
|
||||
}
|
||||
try {
|
||||
val namesJson = prefs.getString(NAMES_STORE_KEY, null)
|
||||
@ -122,7 +122,7 @@ class GeohashBookmarksStore private constructor(private val context: Context) {
|
||||
_bookmarkNames.value = dict
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to load bookmark names: ${e.message}")
|
||||
Log.e(TAG, "Failed to load bookmark names")
|
||||
}
|
||||
}
|
||||
|
||||
@ -155,7 +155,7 @@ class GeohashBookmarksStore private constructor(private val context: Context) {
|
||||
resolving.clear()
|
||||
Log.i(TAG, "Cleared all geohash bookmarks and names")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to clear geohash bookmarks: ${e.message}")
|
||||
Log.e(TAG, "Failed to clear geohash bookmarks")
|
||||
}
|
||||
}
|
||||
|
||||
@ -213,7 +213,7 @@ class GeohashBookmarksStore private constructor(private val context: Context) {
|
||||
persistNames(current)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Name resolution failed for #$gh: ${e.message}")
|
||||
Log.w(TAG, "Bookmark name resolution failed")
|
||||
} finally {
|
||||
resolving.remove(gh)
|
||||
}
|
||||
|
||||
@ -0,0 +1,24 @@
|
||||
package com.bitchat.android.geohash
|
||||
|
||||
internal object GeohashNostrPrivacyPolicy {
|
||||
fun livePresenceTargets(
|
||||
availableChannels: Collection<GeohashChannel>,
|
||||
liveLocationEnabled: Boolean,
|
||||
): Set<String> {
|
||||
if (!liveLocationEnabled) return emptySet()
|
||||
return availableChannels
|
||||
.asSequence()
|
||||
.filter { it.level.precision <= GeohashChannelLevel.CITY.precision }
|
||||
.map { it.geohash }
|
||||
.toSet()
|
||||
}
|
||||
|
||||
fun samplingTargets(
|
||||
liveLocationGeohashes: Collection<String>,
|
||||
userSelectedGeohashes: Collection<String>,
|
||||
liveLocationEnabled: Boolean,
|
||||
): Set<String> = buildSet {
|
||||
addAll(userSelectedGeohashes)
|
||||
if (liveLocationEnabled) addAll(liveLocationGeohashes)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,119 @@
|
||||
package com.bitchat.android.geohash
|
||||
|
||||
import java.util.concurrent.CopyOnWriteArraySet
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
import java.util.concurrent.locks.ReentrantReadWriteLock
|
||||
import kotlin.concurrent.read
|
||||
import kotlin.concurrent.write
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
/**
|
||||
* Process-wide, fail-closed consent gate for accessing live device location.
|
||||
*
|
||||
* A generation token prevents callbacks that were started under an older consent
|
||||
* state from being accepted after location access is disabled or re-enabled.
|
||||
*/
|
||||
internal class LiveLocationAccessPolicy(
|
||||
initialEnabled: Boolean = DEFAULT_LIVE_LOCATION_ENABLED,
|
||||
) {
|
||||
private val accessLock = ReentrantReadWriteLock()
|
||||
private val generation = AtomicLong(0L)
|
||||
private val _enabled = MutableStateFlow(initialEnabled)
|
||||
private var accessAvailable = initialEnabled
|
||||
|
||||
val enabled: StateFlow<Boolean> = _enabled.asStateFlow()
|
||||
val isEnabled: Boolean
|
||||
get() = _enabled.value
|
||||
|
||||
fun update(enabled: Boolean) {
|
||||
accessLock.write {
|
||||
generation.incrementAndGet()
|
||||
_enabled.value = enabled
|
||||
accessAvailable = enabled
|
||||
}
|
||||
}
|
||||
|
||||
fun invalidate() {
|
||||
accessLock.write {
|
||||
generation.incrementAndGet()
|
||||
accessAvailable = false
|
||||
}
|
||||
}
|
||||
|
||||
fun resumeAccess() {
|
||||
accessLock.write {
|
||||
if (_enabled.value && !accessAvailable) {
|
||||
generation.incrementAndGet()
|
||||
accessAvailable = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun captureToken(): Long? =
|
||||
accessLock.read {
|
||||
val capturedGeneration = generation.get()
|
||||
capturedGeneration.takeIf {
|
||||
_enabled.value &&
|
||||
accessAvailable &&
|
||||
generation.get() == capturedGeneration
|
||||
}
|
||||
}
|
||||
|
||||
fun accepts(token: Long): Boolean =
|
||||
accessLock.read {
|
||||
_enabled.value && accessAvailable && generation.get() == token
|
||||
}
|
||||
|
||||
fun runIfAllowed(token: Long, action: () -> Unit): Boolean =
|
||||
accessLock.read {
|
||||
if (!_enabled.value || !accessAvailable || generation.get() != token) {
|
||||
false
|
||||
} else {
|
||||
action()
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal const val DEFAULT_LIVE_LOCATION_ENABLED = false
|
||||
|
||||
internal object LiveLocationPrivacyGate {
|
||||
private val policy = LiveLocationAccessPolicy()
|
||||
private val revocationListeners = CopyOnWriteArraySet<() -> Unit>()
|
||||
|
||||
val enabled: StateFlow<Boolean> = policy.enabled
|
||||
val isEnabled: Boolean
|
||||
get() = policy.isEnabled
|
||||
|
||||
fun update(enabled: Boolean) {
|
||||
policy.update(enabled)
|
||||
notifyRevoked()
|
||||
}
|
||||
|
||||
fun invalidate() {
|
||||
policy.invalidate()
|
||||
notifyRevoked()
|
||||
}
|
||||
|
||||
fun captureToken(): Long? = policy.captureToken()
|
||||
fun resumeAccess() = policy.resumeAccess()
|
||||
fun accepts(token: Long): Boolean = policy.accepts(token)
|
||||
fun runIfAllowed(token: Long, action: () -> Unit): Boolean =
|
||||
policy.runIfAllowed(token, action)
|
||||
|
||||
fun addRevocationListener(listener: () -> Unit) {
|
||||
revocationListeners.add(listener)
|
||||
}
|
||||
|
||||
fun removeRevocationListener(listener: () -> Unit) {
|
||||
revocationListeners.remove(listener)
|
||||
}
|
||||
|
||||
private fun notifyRevoked() {
|
||||
revocationListeners.forEach { listener ->
|
||||
runCatching(listener)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -4,16 +4,14 @@ import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.IntentFilter
|
||||
import android.content.pm.PackageManager
|
||||
import android.location.Geocoder
|
||||
import android.location.Location
|
||||
import android.location.LocationManager
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import androidx.core.app.ActivityCompat
|
||||
import com.google.android.gms.common.ConnectionResult
|
||||
import com.google.android.gms.common.GoogleApiAvailability
|
||||
import com.bitchat.android.nostr.NostrIdentityBridge
|
||||
import kotlinx.coroutines.*
|
||||
import java.util.*
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.JsonSyntaxException
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@ -47,13 +45,19 @@ class LocationChannelManager private constructor(private val context: Context) {
|
||||
AUTHORIZED
|
||||
}
|
||||
|
||||
enum class LocationSelectionSource {
|
||||
NEARBY,
|
||||
MANUAL
|
||||
}
|
||||
|
||||
private val locationManager: LocationManager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
|
||||
private val locationProvider: LocationProvider
|
||||
private val geocoderProvider: GeocoderProvider = GeocoderFactory.get(context)
|
||||
private var lastLocation: Location? = null
|
||||
private var geocodingJob: Job? = null
|
||||
private val gson = Gson()
|
||||
private var dataManager: com.bitchat.android.ui.DataManager? = null
|
||||
private var selectedLocationSource: LocationSelectionSource? = null
|
||||
private var activeLocationUpdateCallback: ((Location) -> Unit)? = null
|
||||
|
||||
private fun checkSystemLocationEnabled(): Boolean {
|
||||
return try {
|
||||
@ -70,14 +74,13 @@ class LocationChannelManager private constructor(private val context: Context) {
|
||||
val isEnabled = checkSystemLocationEnabled()
|
||||
Log.d(TAG, "System location state changed: $isEnabled")
|
||||
_systemLocationEnabled.value = isEnabled
|
||||
if (!isEnabled) {
|
||||
clearLiveLocationState()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val locationUpdateCallback: (Location) -> Unit = { location ->
|
||||
onLocationUpdated(location)
|
||||
}
|
||||
|
||||
// Published state for UI bindings (matching iOS @Published properties)
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
||||
|
||||
@ -99,8 +102,7 @@ class LocationChannelManager private constructor(private val context: Context) {
|
||||
private val _isLoadingLocation = MutableStateFlow(false)
|
||||
val isLoadingLocation: StateFlow<Boolean> = _isLoadingLocation
|
||||
|
||||
private val _locationServicesEnabled = MutableStateFlow(false)
|
||||
val locationServicesEnabled: StateFlow<Boolean> = _locationServicesEnabled
|
||||
val locationServicesEnabled: StateFlow<Boolean> = LiveLocationPrivacyGate.enabled
|
||||
|
||||
private val _systemLocationEnabled = MutableStateFlow(checkSystemLocationEnabled())
|
||||
val systemLocationEnabled: StateFlow<Boolean> = _systemLocationEnabled
|
||||
@ -127,12 +129,14 @@ class LocationChannelManager private constructor(private val context: Context) {
|
||||
Log.i(TAG, "Using SystemLocationProvider (Native LocationManager)")
|
||||
SystemLocationProvider(context)
|
||||
}
|
||||
LiveLocationPrivacyGate.addRevocationListener(::cancelLiveLocationWork)
|
||||
|
||||
checkAndSyncPermission()
|
||||
// Initialize DataManager and load persisted settings
|
||||
dataManager = com.bitchat.android.ui.DataManager(context)
|
||||
loadPersistedChannelSelection()
|
||||
loadLocationServicesState()
|
||||
syncPermissionState()
|
||||
if (!_systemLocationEnabled.value) clearLiveLocationState()
|
||||
loadPersistedChannelSelection()
|
||||
|
||||
// Register for system location changes
|
||||
context.registerReceiver(locationStateReceiver, IntentFilter(LocationManager.PROVIDERS_CHANGED_ACTION))
|
||||
@ -145,17 +149,13 @@ class LocationChannelManager private constructor(private val context: Context) {
|
||||
* UNIFIED: Only requests location if location services are enabled by user
|
||||
*/
|
||||
fun enableLocationChannels() {
|
||||
if (!_locationServicesEnabled.value || !_systemLocationEnabled.value) {
|
||||
if (!LiveLocationPrivacyGate.isEnabled || !_systemLocationEnabled.value) {
|
||||
Log.w(TAG, "Location services disabled (app or system) - not requesting location")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
if (getCurrentPermissionStatus() == PermissionState.AUTHORIZED) {
|
||||
_permissionState.value = PermissionState.AUTHORIZED
|
||||
if (syncPermissionState() == PermissionState.AUTHORIZED) {
|
||||
requestOneShotLocation()
|
||||
} else {
|
||||
_permissionState.value = PermissionState.DENIED
|
||||
}
|
||||
}
|
||||
|
||||
@ -163,7 +163,7 @@ class LocationChannelManager private constructor(private val context: Context) {
|
||||
* Refresh available channels from current location
|
||||
*/
|
||||
fun refreshChannels() {
|
||||
if (_permissionState.value == PermissionState.AUTHORIZED && isLocationServicesEnabled()) {
|
||||
if (syncPermissionState() == PermissionState.AUTHORIZED && isLocationServicesEnabled()) {
|
||||
requestOneShotLocation()
|
||||
}
|
||||
}
|
||||
@ -173,7 +173,7 @@ class LocationChannelManager private constructor(private val context: Context) {
|
||||
* Uses requestLocationUpdates for continuous updates, plus a one-shot to prime state immediately
|
||||
*/
|
||||
fun beginLiveRefresh(interval: Long = 5000L) {
|
||||
if (_permissionState.value != PermissionState.AUTHORIZED) {
|
||||
if (syncPermissionState() != PermissionState.AUTHORIZED) {
|
||||
Log.w(TAG, "Cannot start live refresh - permission not authorized")
|
||||
return
|
||||
}
|
||||
@ -183,12 +183,28 @@ class LocationChannelManager private constructor(private val context: Context) {
|
||||
return
|
||||
}
|
||||
|
||||
endLiveRefresh()
|
||||
LiveLocationPrivacyGate.resumeAccess()
|
||||
val token = LiveLocationPrivacyGate.captureToken() ?: return
|
||||
val callback: (Location) -> Unit = { location ->
|
||||
if (canUseLiveLocation(token)) {
|
||||
onLocationUpdated(location, token)
|
||||
}
|
||||
}
|
||||
activeLocationUpdateCallback = callback
|
||||
|
||||
// Register for continuous updates from available provider
|
||||
locationProvider.requestLocationUpdates(
|
||||
intervalMs = interval,
|
||||
minDistanceMeters = 5f,
|
||||
callback = locationUpdateCallback
|
||||
)
|
||||
val started = LiveLocationPrivacyGate.runIfAllowed(token) {
|
||||
locationProvider.requestLocationUpdates(
|
||||
intervalMs = interval,
|
||||
minDistanceMeters = 5f,
|
||||
callback = callback
|
||||
)
|
||||
}
|
||||
if (!started) {
|
||||
activeLocationUpdateCallback = null
|
||||
return
|
||||
}
|
||||
|
||||
// Prime state immediately with last known / current location
|
||||
requestOneShotLocation()
|
||||
@ -198,53 +214,56 @@ class LocationChannelManager private constructor(private val context: Context) {
|
||||
* Stop periodic refreshes when selector UI is dismissed
|
||||
*/
|
||||
fun endLiveRefresh() {
|
||||
locationProvider.removeLocationUpdates(locationUpdateCallback)
|
||||
activeLocationUpdateCallback?.let(locationProvider::removeLocationUpdates)
|
||||
activeLocationUpdateCallback = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a channel
|
||||
* Generic selection is intentionally treated as manual. GPS-derived selections must use
|
||||
* [selectNearby] so their provenance can be revoked when live location is disabled.
|
||||
*/
|
||||
fun select(channel: ChannelID) {
|
||||
Log.d(TAG, "Selected channel: ${channel.displayName}")
|
||||
// Use synchronous set to avoid race with background recomputation
|
||||
_selectedChannel.value = channel
|
||||
saveChannelSelection(channel)
|
||||
|
||||
// Immediately recompute teleported status against the latest known location
|
||||
lastLocation?.let { location ->
|
||||
when (channel) {
|
||||
is ChannelID.Mesh -> {
|
||||
_teleported.value = false
|
||||
}
|
||||
is ChannelID.Location -> {
|
||||
val currentGeohash = Geohash.encode(
|
||||
latitude = location.latitude,
|
||||
longitude = location.longitude,
|
||||
precision = channel.channel.level.precision
|
||||
)
|
||||
val isTeleportedNow = currentGeohash != channel.channel.geohash
|
||||
_teleported.value = isTeleportedNow
|
||||
}
|
||||
}
|
||||
when (channel) {
|
||||
ChannelID.Mesh -> selectInternal(ChannelID.Mesh, source = null, teleported = false)
|
||||
is ChannelID.Location -> selectManual(channel.channel)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set teleported status (for manual geohash teleportation)
|
||||
*/
|
||||
fun setTeleported(teleported: Boolean) {
|
||||
_teleported.value = teleported
|
||||
|
||||
fun selectNearby(channel: GeohashChannel): Boolean {
|
||||
val isCurrentNearbyChannel = _availableChannels.value.contains(channel)
|
||||
if (!isCurrentNearbyChannel || !isLocationServicesEnabled() ||
|
||||
syncPermissionState() != PermissionState.AUTHORIZED
|
||||
) {
|
||||
Log.w(TAG, "Blocked nearby channel selection without live-location access")
|
||||
return false
|
||||
}
|
||||
selectInternal(
|
||||
ChannelID.Location(channel),
|
||||
source = LocationSelectionSource.NEARBY,
|
||||
teleported = false
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
fun selectManual(channel: GeohashChannel, teleported: Boolean = true) {
|
||||
selectInternal(
|
||||
ChannelID.Location(channel),
|
||||
source = LocationSelectionSource.MANUAL,
|
||||
teleported = teleported || !LiveLocationPrivacyGate.isEnabled
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable location services (user-controlled toggle)
|
||||
*/
|
||||
fun enableLocationServices() {
|
||||
_locationServicesEnabled.value = true
|
||||
saveLocationServicesState(true)
|
||||
if (!LiveLocationPrivacyGate.isEnabled) {
|
||||
LiveLocationPrivacyGate.update(true)
|
||||
saveLocationServicesState(true)
|
||||
}
|
||||
|
||||
// If we have permission and system location is on, start location operations
|
||||
if (_permissionState.value == PermissionState.AUTHORIZED && systemLocationEnabled.value) {
|
||||
if (syncPermissionState() == PermissionState.AUTHORIZED && systemLocationEnabled.value) {
|
||||
requestOneShotLocation()
|
||||
}
|
||||
}
|
||||
@ -253,20 +272,9 @@ class LocationChannelManager private constructor(private val context: Context) {
|
||||
* Disable location services (user-controlled toggle)
|
||||
*/
|
||||
fun disableLocationServices() {
|
||||
_locationServicesEnabled.value = false
|
||||
LiveLocationPrivacyGate.update(false)
|
||||
saveLocationServicesState(false)
|
||||
|
||||
// Stop any ongoing location operations
|
||||
endLiveRefresh()
|
||||
|
||||
// Clear available channels when location is disabled
|
||||
_availableChannels.value = emptyList()
|
||||
_locationNames.value = emptyMap()
|
||||
|
||||
// If user had a location channel selected, switch back to mesh
|
||||
if (_selectedChannel.value is ChannelID.Location) {
|
||||
select(ChannelID.Mesh)
|
||||
}
|
||||
clearLiveLocationState(invalidateAccess = false)
|
||||
}
|
||||
|
||||
/**
|
||||
@ -276,70 +284,163 @@ class LocationChannelManager private constructor(private val context: Context) {
|
||||
* Check if both the app toggle and system location are enabled
|
||||
*/
|
||||
fun isLocationServicesEnabled(): Boolean {
|
||||
return _locationServicesEnabled.value && _systemLocationEnabled.value
|
||||
return LiveLocationPrivacyGate.isEnabled && _systemLocationEnabled.value
|
||||
}
|
||||
|
||||
fun canUseSelectedLocationChannel(channel: GeohashChannel): Boolean {
|
||||
if (_selectedChannel.value != ChannelID.Location(channel)) return false
|
||||
return selectedLocationSource == LocationSelectionSource.MANUAL ||
|
||||
LiveLocationPrivacyGate.captureToken() != null
|
||||
}
|
||||
|
||||
fun isSelectedChannelLiveDerived(channel: GeohashChannel): Boolean =
|
||||
_selectedChannel.value == ChannelID.Location(channel) &&
|
||||
selectedLocationSource == LocationSelectionSource.NEARBY
|
||||
|
||||
fun liveLocationTokenForSelectedChannel(channel: GeohashChannel): Long? {
|
||||
if (!isSelectedChannelLiveDerived(channel)) return null
|
||||
return LiveLocationPrivacyGate.captureToken()
|
||||
}
|
||||
|
||||
private fun selectInternal(
|
||||
channel: ChannelID,
|
||||
source: LocationSelectionSource?,
|
||||
teleported: Boolean
|
||||
) {
|
||||
selectedLocationSource = source
|
||||
_teleported.value = when (channel) {
|
||||
ChannelID.Mesh -> false
|
||||
is ChannelID.Location -> teleported
|
||||
}
|
||||
_selectedChannel.value = channel
|
||||
saveChannelSelection(channel, source)
|
||||
}
|
||||
|
||||
/**
|
||||
* Revokes all GPS-derived in-memory state and pending work. This deliberately does not
|
||||
* change the persisted soft setting when Android's hard permission or system provider is
|
||||
* temporarily unavailable.
|
||||
*/
|
||||
private fun clearLiveLocationState(invalidateAccess: Boolean = true) {
|
||||
if (invalidateAccess) LiveLocationPrivacyGate.invalidate()
|
||||
cancelLiveLocationWork()
|
||||
_isLoadingLocation.value = false
|
||||
NostrIdentityBridge.clearGeohashIdentityCache(
|
||||
_availableChannels.value.map { it.geohash }
|
||||
)
|
||||
_availableChannels.value = emptyList()
|
||||
_locationNames.value = emptyMap()
|
||||
|
||||
when {
|
||||
_selectedChannel.value is ChannelID.Location &&
|
||||
selectedLocationSource == LocationSelectionSource.NEARBY -> {
|
||||
selectInternal(ChannelID.Mesh, source = null, teleported = false)
|
||||
}
|
||||
_selectedChannel.value is ChannelID.Location -> {
|
||||
// Manual channels remain usable for teleports, bookmarks, and DMs. Never use
|
||||
// a previously retained GPS fix to classify them while live access is off.
|
||||
selectedLocationSource = LocationSelectionSource.MANUAL
|
||||
_teleported.value = true
|
||||
saveChannelSelection(_selectedChannel.value, selectedLocationSource)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun cancelLiveLocationWork() {
|
||||
locationProvider.cancel()
|
||||
activeLocationUpdateCallback = null
|
||||
geocodingJob?.cancel()
|
||||
geocodingJob = null
|
||||
}
|
||||
|
||||
// MARK: - Location Operations
|
||||
|
||||
private fun requestOneShotLocation() {
|
||||
if (!checkAndSyncPermission()) {
|
||||
if (!isLocationServicesEnabled() ||
|
||||
syncPermissionState() != PermissionState.AUTHORIZED
|
||||
) {
|
||||
Log.w(TAG, "No location permission for one-shot request")
|
||||
return
|
||||
}
|
||||
|
||||
// Set loading state initially
|
||||
LiveLocationPrivacyGate.resumeAccess()
|
||||
val token = LiveLocationPrivacyGate.captureToken() ?: return
|
||||
_isLoadingLocation.value = true
|
||||
|
||||
locationProvider.getLastKnownLocation { cached ->
|
||||
// If we have a cached location and it's reasonably recent (e.g. < 5 mins), use it
|
||||
// For now, we just use it if it exists, similar to previous logic
|
||||
if (cached != null) {
|
||||
onLocationUpdated(cached)
|
||||
} else {
|
||||
locationProvider.requestFreshLocation { fresh ->
|
||||
if (fresh != null) {
|
||||
onLocationUpdated(fresh)
|
||||
} else {
|
||||
Log.w(TAG, "Failed to get fresh location")
|
||||
_isLoadingLocation.value = false
|
||||
val started = LiveLocationPrivacyGate.runIfAllowed(token) {
|
||||
locationProvider.getLastKnownLocation { cached ->
|
||||
if (!canUseLiveLocation(token)) return@getLastKnownLocation
|
||||
|
||||
if (cached != null) {
|
||||
onLocationUpdated(cached, token)
|
||||
} else {
|
||||
LiveLocationPrivacyGate.runIfAllowed(token) {
|
||||
locationProvider.requestFreshLocation { fresh ->
|
||||
if (!canUseLiveLocation(token)) return@requestFreshLocation
|
||||
|
||||
if (fresh != null) {
|
||||
onLocationUpdated(fresh, token)
|
||||
} else {
|
||||
Log.w(TAG, "Failed to get fresh location")
|
||||
_isLoadingLocation.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!started) _isLoadingLocation.value = false
|
||||
}
|
||||
|
||||
private fun onLocationUpdated(location: Location) {
|
||||
lastLocation = location
|
||||
_isLoadingLocation.value = false
|
||||
computeChannels(location)
|
||||
reverseGeocodeIfNeeded(location)
|
||||
private fun onLocationUpdated(location: Location, token: Long) {
|
||||
LiveLocationPrivacyGate.runIfAllowed(token) {
|
||||
if (!_systemLocationEnabled.value || !hasRuntimeLocationPermission()) return@runIfAllowed
|
||||
_isLoadingLocation.value = false
|
||||
computeChannels(location, token)
|
||||
reverseGeocodeIfNeeded(location, token)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private fun getCurrentPermissionStatus(): PermissionState {
|
||||
return if (checkAndSyncPermission()) {
|
||||
private fun hasRuntimeLocationPermission(): Boolean {
|
||||
return ActivityCompat.checkSelfPermission(
|
||||
context,
|
||||
Manifest.permission.ACCESS_FINE_LOCATION
|
||||
) == PackageManager.PERMISSION_GRANTED ||
|
||||
ActivityCompat.checkSelfPermission(
|
||||
context,
|
||||
Manifest.permission.ACCESS_COARSE_LOCATION
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
fun syncPermissionState(): PermissionState {
|
||||
val newState = if (hasRuntimeLocationPermission()) {
|
||||
PermissionState.AUTHORIZED
|
||||
} else {
|
||||
PermissionState.DENIED
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkAndSyncPermission(): Boolean {
|
||||
val hasPermission = ActivityCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED ||
|
||||
ActivityCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
|
||||
|
||||
val newState = if (hasPermission) PermissionState.AUTHORIZED else PermissionState.DENIED
|
||||
|
||||
if (_permissionState.value != newState) {
|
||||
_permissionState.value = newState
|
||||
}
|
||||
|
||||
return hasPermission
|
||||
if (newState == PermissionState.DENIED) {
|
||||
clearLiveLocationState()
|
||||
}
|
||||
return newState
|
||||
}
|
||||
|
||||
private fun computeChannels(location: Location) {
|
||||
private fun canUseLiveLocation(token: Long): Boolean {
|
||||
return LiveLocationPrivacyGate.accepts(token) &&
|
||||
_systemLocationEnabled.value &&
|
||||
hasRuntimeLocationPermission()
|
||||
}
|
||||
|
||||
private fun computeChannels(location: Location, token: Long) {
|
||||
if (!canUseLiveLocation(token)) return
|
||||
|
||||
val levels = GeohashChannelLevel.allCases()
|
||||
val result = mutableListOf<GeohashChannel>()
|
||||
|
||||
@ -351,47 +452,55 @@ class LocationChannelManager private constructor(private val context: Context) {
|
||||
)
|
||||
result.add(GeohashChannel(level = level, geohash = geohash))
|
||||
}
|
||||
|
||||
|
||||
if (!canUseLiveLocation(token)) return
|
||||
_availableChannels.value = result
|
||||
|
||||
// Recompute teleported status based on current location vs selected channel
|
||||
val selectedChannelValue = _selectedChannel.value
|
||||
when (selectedChannelValue) {
|
||||
is ChannelID.Mesh -> {
|
||||
_teleported.value = false
|
||||
}
|
||||
is ChannelID.Location -> {
|
||||
val currentGeohash = Geohash.encode(
|
||||
latitude = location.latitude,
|
||||
longitude = location.longitude,
|
||||
precision = selectedChannelValue.channel.level.precision
|
||||
)
|
||||
val isTeleported = currentGeohash != selectedChannelValue.channel.geohash
|
||||
_teleported.value = isTeleported
|
||||
}
|
||||
if (selectedChannelValue is ChannelID.Location &&
|
||||
selectedLocationSource == LocationSelectionSource.NEARBY
|
||||
) {
|
||||
val currentGeohash = Geohash.encode(
|
||||
latitude = location.latitude,
|
||||
longitude = location.longitude,
|
||||
precision = selectedChannelValue.channel.level.precision
|
||||
)
|
||||
_teleported.value = currentGeohash != selectedChannelValue.channel.geohash
|
||||
} else if (selectedChannelValue is ChannelID.Mesh) {
|
||||
_teleported.value = false
|
||||
}
|
||||
}
|
||||
|
||||
private fun reverseGeocodeIfNeeded(location: Location) {
|
||||
// Cancel any pending geocoding job to avoid race conditions
|
||||
private fun reverseGeocodeIfNeeded(location: Location, token: Long) {
|
||||
if (!canUseLiveLocation(token)) return
|
||||
geocodingJob?.cancel()
|
||||
|
||||
geocodingJob = scope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val addresses = geocoderProvider.getFromLocation(location.latitude, location.longitude, 1)
|
||||
if (!canUseLiveLocation(token)) return@launch
|
||||
val addresses = geocoderProvider.getFromLocation(
|
||||
location.latitude,
|
||||
location.longitude,
|
||||
1,
|
||||
liveLocationToken = token
|
||||
)
|
||||
|
||||
if (!isActive) return@launch
|
||||
if (!isActive || !canUseLiveLocation(token)) return@launch
|
||||
|
||||
if (addresses.isNotEmpty()) {
|
||||
val address = addresses[0]
|
||||
val names = namesByLevel(address)
|
||||
_locationNames.value = names
|
||||
LiveLocationPrivacyGate.runIfAllowed(token) {
|
||||
if (_systemLocationEnabled.value && hasRuntimeLocationPermission()) {
|
||||
_locationNames.value = names
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Log.w(TAG, "No reverse geocoding results")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e !is CancellationException) {
|
||||
Log.e(TAG, "Reverse geocoding failed: ${e.message}")
|
||||
Log.e(TAG, "Reverse geocoding failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -445,7 +554,10 @@ class LocationChannelManager private constructor(private val context: Context) {
|
||||
/**
|
||||
* Save current channel selection to persistent storage
|
||||
*/
|
||||
private fun saveChannelSelection(channel: ChannelID) {
|
||||
private fun saveChannelSelection(
|
||||
channel: ChannelID,
|
||||
source: LocationSelectionSource?
|
||||
) {
|
||||
try {
|
||||
val channelData = when (channel) {
|
||||
is ChannelID.Mesh -> gson.toJson(PersistedChannel(mesh = true))
|
||||
@ -453,13 +565,14 @@ class LocationChannelManager private constructor(private val context: Context) {
|
||||
PersistedChannel(
|
||||
mesh = false,
|
||||
level = channel.channel.level.name,
|
||||
geohash = channel.channel.geohash
|
||||
geohash = channel.channel.geohash,
|
||||
source = source?.name
|
||||
)
|
||||
)
|
||||
}
|
||||
dataManager?.saveLastGeohashChannel(channelData)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to save channel selection: ${e.message}")
|
||||
Log.e(TAG, "Failed to save channel selection")
|
||||
}
|
||||
}
|
||||
|
||||
@ -472,27 +585,44 @@ class LocationChannelManager private constructor(private val context: Context) {
|
||||
if (!channelData.isNullOrBlank()) {
|
||||
val persisted = gson.fromJson(channelData, PersistedChannel::class.java)
|
||||
val channel = persisted?.toChannel()
|
||||
if (channel != null) {
|
||||
val source = persisted?.selectionSource()
|
||||
val canRestore = channel !is ChannelID.Location ||
|
||||
source == LocationSelectionSource.MANUAL ||
|
||||
(LiveLocationPrivacyGate.isEnabled &&
|
||||
_systemLocationEnabled.value &&
|
||||
_permissionState.value == PermissionState.AUTHORIZED)
|
||||
|
||||
if (channel != null && canRestore) {
|
||||
_selectedChannel.value = channel
|
||||
selectedLocationSource = if (channel is ChannelID.Location) source else null
|
||||
_teleported.value = channel is ChannelID.Location &&
|
||||
source == LocationSelectionSource.MANUAL
|
||||
} else {
|
||||
_selectedChannel.value = ChannelID.Mesh
|
||||
selectedLocationSource = null
|
||||
_teleported.value = false
|
||||
saveChannelSelection(ChannelID.Mesh, source = null)
|
||||
}
|
||||
} else {
|
||||
_selectedChannel.value = ChannelID.Mesh
|
||||
selectedLocationSource = null
|
||||
}
|
||||
} catch (e: JsonSyntaxException) {
|
||||
Log.e(TAG, "Failed to parse persisted channel data: ${e.message}")
|
||||
Log.e(TAG, "Failed to parse persisted channel data")
|
||||
_selectedChannel.value = ChannelID.Mesh
|
||||
selectedLocationSource = null
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to load persisted channel: ${e.message}")
|
||||
Log.e(TAG, "Failed to load persisted channel")
|
||||
_selectedChannel.value = ChannelID.Mesh
|
||||
selectedLocationSource = null
|
||||
}
|
||||
}
|
||||
|
||||
data class PersistedChannel(
|
||||
val mesh: Boolean,
|
||||
val level: String? = null,
|
||||
val geohash: String? = null
|
||||
val geohash: String? = null,
|
||||
val source: String? = null
|
||||
) {
|
||||
fun toChannel(): ChannelID? {
|
||||
return if (mesh) {
|
||||
@ -503,6 +633,13 @@ class LocationChannelManager private constructor(private val context: Context) {
|
||||
ChannelID.Location.fromPersisted(levelName, gh)
|
||||
}
|
||||
}
|
||||
|
||||
fun selectionSource(): LocationSelectionSource? {
|
||||
if (mesh) return null
|
||||
return source?.let {
|
||||
runCatching { LocationSelectionSource.valueOf(it) }.getOrNull()
|
||||
} ?: LocationSelectionSource.NEARBY
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -511,6 +648,7 @@ class LocationChannelManager private constructor(private val context: Context) {
|
||||
fun clearPersistedChannel() {
|
||||
dataManager?.clearLastGeohashChannel()
|
||||
_selectedChannel.value = ChannelID.Mesh
|
||||
selectedLocationSource = null
|
||||
_teleported.value = false
|
||||
}
|
||||
|
||||
@ -523,7 +661,7 @@ class LocationChannelManager private constructor(private val context: Context) {
|
||||
try {
|
||||
dataManager?.saveLocationServicesEnabled(enabled)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to save location services state: ${e.message}")
|
||||
Log.e(TAG, "Failed to save location services state")
|
||||
}
|
||||
}
|
||||
|
||||
@ -533,10 +671,10 @@ class LocationChannelManager private constructor(private val context: Context) {
|
||||
private fun loadLocationServicesState() {
|
||||
try {
|
||||
val enabled = dataManager?.isLocationServicesEnabled() ?: false
|
||||
_locationServicesEnabled.value = enabled
|
||||
LiveLocationPrivacyGate.update(enabled)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to load location services state: ${e.message}")
|
||||
_locationServicesEnabled.value = false
|
||||
Log.e(TAG, "Failed to load location services state")
|
||||
LiveLocationPrivacyGate.update(false)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -6,7 +6,7 @@ import android.location.Location
|
||||
* Abstraction for location providers to support both
|
||||
* System (LocationManager) and Google Play Services (FusedLocationProvider).
|
||||
*/
|
||||
interface LocationProvider {
|
||||
internal interface LocationProvider {
|
||||
/**
|
||||
* Get the last known location from cache.
|
||||
* @param callback Called with the location or null if not found/error.
|
||||
|
||||
@ -4,53 +4,79 @@ import android.location.Address
|
||||
import android.util.Log
|
||||
import com.bitchat.android.net.OkHttpProvider
|
||||
import com.google.gson.Gson
|
||||
import java.io.IOException
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import okhttp3.Call
|
||||
import okhttp3.Callback
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import java.util.Locale
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
class OpenStreetMapGeocoderProvider : GeocoderProvider {
|
||||
private val TAG = "OSMGeocoderProvider"
|
||||
private val gson = Gson()
|
||||
private val userAgent = "Bitchat-Android/1.0"
|
||||
|
||||
override suspend fun getFromLocation(latitude: Double, longitude: Double, maxResults: Int): List<Address> {
|
||||
return withContext(Dispatchers.IO) {
|
||||
override suspend fun getFromLocation(
|
||||
latitude: Double,
|
||||
longitude: Double,
|
||||
maxResults: Int,
|
||||
liveLocationToken: Long?
|
||||
): List<Address> {
|
||||
return suspendCancellableCoroutine { continuation ->
|
||||
val lang = Locale.getDefault().toLanguageTag()
|
||||
// Using format=jsonv2 for structured address breakdown
|
||||
val url = "https://nominatim.openstreetmap.org/reverse?format=jsonv2&lat=$latitude&lon=$longitude&zoom=18&addressdetails=1&accept-language=$lang"
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.header("User-Agent", userAgent)
|
||||
.build()
|
||||
val call = OkHttpProvider.httpClient().newCall(request)
|
||||
|
||||
try {
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.header("User-Agent", userAgent)
|
||||
.build()
|
||||
|
||||
val response = OkHttpProvider.httpClient().newCall(request).execute()
|
||||
if (!response.isSuccessful) {
|
||||
Log.e(TAG, "OSM Request failed: ${response.code}")
|
||||
response.close()
|
||||
return@withContext emptyList<Address>()
|
||||
continuation.invokeOnCancellation { call.cancel() }
|
||||
val enqueueRequest = {
|
||||
call.enqueue(object : Callback {
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
if (continuation.isActive) {
|
||||
Log.w(TAG, "OSM geocoding request failed")
|
||||
continuation.resume(emptyList())
|
||||
}
|
||||
}
|
||||
|
||||
val body = response.body?.string()
|
||||
response.close()
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
val addresses = response.use {
|
||||
if (!it.isSuccessful) {
|
||||
Log.w(TAG, "OSM geocoding request returned ${it.code}")
|
||||
return@use emptyList()
|
||||
}
|
||||
|
||||
if (body.isNullOrEmpty()) return@withContext emptyList<Address>()
|
||||
val body = it.body?.string()
|
||||
if (body.isNullOrEmpty()) return@use emptyList()
|
||||
|
||||
try {
|
||||
val osmResponse = gson.fromJson(body, OsmResponse::class.java)
|
||||
if (osmResponse?.address == null) return@withContext emptyList<Address>()
|
||||
|
||||
val address = mapToAddress(osmResponse, latitude, longitude)
|
||||
listOf(address)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "OSM Parse failed: ${e.message}")
|
||||
emptyList<Address>()
|
||||
runCatching {
|
||||
val osmResponse = gson.fromJson(body, OsmResponse::class.java)
|
||||
if (osmResponse?.address == null) emptyList()
|
||||
else listOf(mapToAddress(osmResponse, latitude, longitude))
|
||||
}.getOrElse {
|
||||
Log.w(TAG, "OSM geocoding response could not be parsed")
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
if (continuation.isActive) continuation.resume(addresses)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "OSM Geocoding failed", e)
|
||||
emptyList<Address>()
|
||||
})
|
||||
}
|
||||
val started = if (liveLocationToken == null) {
|
||||
enqueueRequest()
|
||||
true
|
||||
} else {
|
||||
LiveLocationPrivacyGate.runIfAllowed(
|
||||
liveLocationToken,
|
||||
enqueueRequest
|
||||
)
|
||||
}
|
||||
if (!started && continuation.isActive) {
|
||||
continuation.resume(emptyList())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,10 +9,11 @@ import android.location.LocationListener
|
||||
import android.location.LocationManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.CancellationSignal
|
||||
import android.util.Log
|
||||
import androidx.core.app.ActivityCompat
|
||||
|
||||
class SystemLocationProvider(private val context: Context) : LocationProvider {
|
||||
internal class SystemLocationProvider(private val context: Context) : LocationProvider {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "SystemLocationProvider"
|
||||
@ -25,10 +26,13 @@ class SystemLocationProvider(private val context: Context) : LocationProvider {
|
||||
private val activeListeners = mutableMapOf<(Location) -> Unit, LocationListener>()
|
||||
private val activeOneShotListeners = mutableMapOf<(Location?) -> Unit, LocationListener>()
|
||||
private val activeOneShotRunnables = mutableMapOf<(Location?) -> Unit, Runnable>()
|
||||
private val activeOneShotCancellationSignals = mutableMapOf<(Location?) -> Unit, CancellationSignal>()
|
||||
|
||||
private fun hasLocationPermission(): Boolean {
|
||||
return ActivityCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED ||
|
||||
return LiveLocationPrivacyGate.isEnabled &&
|
||||
(ActivityCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED ||
|
||||
ActivityCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
|
||||
)
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
@ -49,9 +53,9 @@ class SystemLocationProvider(private val context: Context) : LocationProvider {
|
||||
}
|
||||
}
|
||||
}
|
||||
callback(bestLocation)
|
||||
callback(bestLocation.takeIf { LiveLocationPrivacyGate.isEnabled })
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error getting last known location: ${e.message}")
|
||||
Log.e(TAG, "Error getting last-known location")
|
||||
callback(null)
|
||||
}
|
||||
}
|
||||
@ -76,12 +80,27 @@ class SystemLocationProvider(private val context: Context) : LocationProvider {
|
||||
Log.d(TAG, "Requesting fresh location from $provider")
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
locationManager.getCurrentLocation(
|
||||
provider,
|
||||
null,
|
||||
context.mainExecutor
|
||||
) { location ->
|
||||
callback(location)
|
||||
val cancellationSignal = CancellationSignal()
|
||||
synchronized(activeOneShotCancellationSignals) {
|
||||
activeOneShotCancellationSignals[callback] = cancellationSignal
|
||||
}
|
||||
try {
|
||||
locationManager.getCurrentLocation(
|
||||
provider,
|
||||
cancellationSignal,
|
||||
context.mainExecutor
|
||||
) { location ->
|
||||
synchronized(activeOneShotCancellationSignals) {
|
||||
activeOneShotCancellationSignals.remove(callback)
|
||||
}
|
||||
callback(location.takeIf { LiveLocationPrivacyGate.isEnabled })
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
synchronized(activeOneShotCancellationSignals) {
|
||||
activeOneShotCancellationSignals.remove(callback)
|
||||
}
|
||||
cancellationSignal.cancel()
|
||||
throw e
|
||||
}
|
||||
} else {
|
||||
// For older versions, use requestSingleUpdate with timeout mechanism
|
||||
@ -94,7 +113,7 @@ class SystemLocationProvider(private val context: Context) : LocationProvider {
|
||||
try {
|
||||
locationManager.removeUpdates(listener)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error removing timed out listener: ${e.message}")
|
||||
Log.e(TAG, "Error removing timed-out listener")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -113,9 +132,9 @@ class SystemLocationProvider(private val context: Context) : LocationProvider {
|
||||
try {
|
||||
locationManager.removeUpdates(this)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error removing updates in callback: ${e.message}")
|
||||
Log.e(TAG, "Error removing updates in callback")
|
||||
}
|
||||
callback(location)
|
||||
callback(location.takeIf { LiveLocationPrivacyGate.isEnabled })
|
||||
}
|
||||
override fun onStatusChanged(provider: String?, status: Int, extras: Bundle?) {}
|
||||
override fun onProviderEnabled(provider: String) {}
|
||||
@ -140,7 +159,7 @@ class SystemLocationProvider(private val context: Context) : LocationProvider {
|
||||
callback(null)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error requesting fresh location: ${e.message}")
|
||||
Log.e(TAG, "Error requesting fresh location")
|
||||
callback(null)
|
||||
}
|
||||
}
|
||||
@ -156,7 +175,7 @@ class SystemLocationProvider(private val context: Context) : LocationProvider {
|
||||
try {
|
||||
val listener = object : LocationListener {
|
||||
override fun onLocationChanged(location: Location) {
|
||||
callback(location)
|
||||
if (LiveLocationPrivacyGate.isEnabled) callback(location)
|
||||
}
|
||||
override fun onStatusChanged(provider: String?, status: Int, extras: Bundle?) {}
|
||||
override fun onProviderEnabled(provider: String) {}
|
||||
@ -189,7 +208,7 @@ class SystemLocationProvider(private val context: Context) : LocationProvider {
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error requesting location updates: ${e.message}")
|
||||
Log.e(TAG, "Error requesting location updates")
|
||||
}
|
||||
}
|
||||
|
||||
@ -204,7 +223,7 @@ class SystemLocationProvider(private val context: Context) : LocationProvider {
|
||||
Log.d(TAG, "Removed location updates")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error removing updates: ${e.message}")
|
||||
Log.e(TAG, "Error removing updates")
|
||||
}
|
||||
}
|
||||
|
||||
@ -230,9 +249,13 @@ class SystemLocationProvider(private val context: Context) : LocationProvider {
|
||||
}
|
||||
activeOneShotRunnables.clear()
|
||||
}
|
||||
synchronized(activeOneShotCancellationSignals) {
|
||||
activeOneShotCancellationSignals.values.forEach { it.cancel() }
|
||||
activeOneShotCancellationSignals.clear()
|
||||
}
|
||||
Log.d(TAG, "Cancelled all system location requests")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error cancelling system provider: ${e.message}")
|
||||
Log.e(TAG, "Error cancelling system provider")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -30,12 +30,12 @@ import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.bitchat.android.ui.theme.BitchatFontFamily
|
||||
import com.bitchat.android.ui.theme.BitchatTheme
|
||||
import com.bitchat.android.util.UniversalApkManager
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
@ -112,7 +112,7 @@ fun HotspotScreen(
|
||||
title = {
|
||||
Text(
|
||||
text = "Share BitChat",
|
||||
fontFamily = FontFamily.Monospace
|
||||
fontFamily = BitchatFontFamily
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
@ -398,7 +398,7 @@ fun ActiveHotspotScreen(state: HotspotViewModel.HotspotState.Active) {
|
||||
text = {
|
||||
Text(
|
||||
text = title,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontWeight = if (selectedTab == index) FontWeight.Bold else FontWeight.Normal
|
||||
)
|
||||
}
|
||||
@ -615,7 +615,7 @@ fun CredentialCard(
|
||||
Text(
|
||||
text = value,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer
|
||||
)
|
||||
|
||||
@ -69,7 +69,15 @@ data class BitchatMessage(
|
||||
val encryptedContent: ByteArray? = null,
|
||||
val isEncrypted: Boolean = false,
|
||||
val deliveryStatus: DeliveryStatus? = null,
|
||||
val powDifficulty: Int? = null
|
||||
val powDifficulty: Int? = null,
|
||||
/**
|
||||
* Full canonical Nostr public key supplied by the local Nostr bridge.
|
||||
*
|
||||
* This is local identity metadata, not part of the Bitchat binary wire format. It lets UI
|
||||
* surfaces color the sender by the same stable key while [senderPeerID] remains available for
|
||||
* mesh IDs and private-chat routing aliases.
|
||||
*/
|
||||
val senderNostrPubkey: String? = null
|
||||
) : Parcelable {
|
||||
|
||||
/**
|
||||
@ -355,4 +363,3 @@ data class BitchatMessage(
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -96,6 +96,7 @@ class GeohashMessageHandler(
|
||||
isRelay = false,
|
||||
originalSender = repo.displayNameForNostrPubkey(pubkey),
|
||||
senderPeerID = "nostr:${pubkey.take(8)}",
|
||||
senderNostrPubkey = pubkey,
|
||||
mentions = null,
|
||||
channel = "#$subscribedGeohash",
|
||||
powDifficulty = try {
|
||||
|
||||
@ -28,25 +28,33 @@ object LocationNotesInitializer {
|
||||
return@initialize id // Return subscription ID even on error
|
||||
}
|
||||
|
||||
Log.d(TAG, "📍 Location Notes subscribing to geohash: $geohashFromFilter")
|
||||
|
||||
val token = com.bitchat.android.geohash.LiveLocationPrivacyGate
|
||||
.captureToken() ?: return@initialize id
|
||||
NostrRelayManager.getInstance(context).subscribeForGeohash(
|
||||
geohash = geohashFromFilter,
|
||||
filter = filter,
|
||||
id = id,
|
||||
handler = handler,
|
||||
includeDefaults = true,
|
||||
nRelays = 5
|
||||
nRelays = 5,
|
||||
liveLocationToken = token
|
||||
)
|
||||
},
|
||||
unsubscribe = { id ->
|
||||
NostrRelayManager.getInstance(context).unsubscribe(id)
|
||||
},
|
||||
sendEvent = { event, relayUrls ->
|
||||
sendEvent = { event, relayUrls, token ->
|
||||
if (relayUrls != null) {
|
||||
NostrRelayManager.getInstance(context).sendEvent(event, relayUrls)
|
||||
NostrRelayManager.getInstance(context).sendEvent(
|
||||
event,
|
||||
relayUrls,
|
||||
liveLocationToken = token
|
||||
)
|
||||
} else {
|
||||
NostrRelayManager.getInstance(context).sendEvent(event)
|
||||
NostrRelayManager.getInstance(context).sendEvent(
|
||||
event,
|
||||
liveLocationToken = token
|
||||
)
|
||||
}
|
||||
},
|
||||
deriveIdentity = { geohash ->
|
||||
|
||||
@ -2,10 +2,12 @@ package com.bitchat.android.nostr
|
||||
|
||||
import android.util.Log
|
||||
import androidx.annotation.MainThread
|
||||
import com.bitchat.android.geohash.LiveLocationPrivacyGate
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Manages location notes (kind=1 text notes with geohash tags)
|
||||
@ -89,13 +91,18 @@ class LocationNotesManager private constructor() {
|
||||
private var relayLookup: (() -> NostrRelayManager)? = null
|
||||
private var subscribeFunc: ((NostrFilter, String, (NostrEvent) -> Unit) -> String)? = null
|
||||
private var unsubscribeFunc: ((String) -> Unit)? = null
|
||||
private var sendEventFunc: ((NostrEvent, List<String>?) -> Unit)? = null
|
||||
private var sendEventFunc: ((NostrEvent, List<String>?, Long) -> Unit)? = null
|
||||
private var deriveIdentityFunc: ((String) -> NostrIdentity)? = null
|
||||
|
||||
// Coroutine scope for background operations
|
||||
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
|
||||
private var liveLocationToken: Long? = null
|
||||
private var subscribeRetryJob: Job? = null
|
||||
private var initialLoadJob: Job? = null
|
||||
|
||||
init {
|
||||
LiveLocationPrivacyGate.addRevocationListener(::stop)
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize dependencies
|
||||
@ -104,7 +111,7 @@ class LocationNotesManager private constructor() {
|
||||
relayManager: () -> NostrRelayManager,
|
||||
subscribe: (NostrFilter, String, (NostrEvent) -> Unit) -> String,
|
||||
unsubscribe: (String) -> Unit,
|
||||
sendEvent: (NostrEvent, List<String>?) -> Unit,
|
||||
sendEvent: (NostrEvent, List<String>?, Long) -> Unit,
|
||||
deriveIdentity: (String) -> NostrIdentity
|
||||
) {
|
||||
this.relayLookup = relayManager
|
||||
@ -119,23 +126,28 @@ class LocationNotesManager private constructor() {
|
||||
* iOS: Validates building-level precision (8 characters)
|
||||
*/
|
||||
fun setGeohash(newGeohash: String) {
|
||||
val token = LiveLocationPrivacyGate.captureToken() ?: run {
|
||||
stop()
|
||||
return
|
||||
}
|
||||
val normalized = newGeohash.lowercase()
|
||||
|
||||
if (_geohash.value == normalized) {
|
||||
Log.d(TAG, "Geohash unchanged, skipping: $normalized")
|
||||
if (_geohash.value == normalized &&
|
||||
liveLocationToken?.let(LiveLocationPrivacyGate::accepts) == true
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
// Validate geohash (building-level precision: 8 chars) - matches iOS
|
||||
if (!isValidBuildingGeohash(normalized)) {
|
||||
Log.w(TAG, "LocationNotesManager: rejecting invalid geohash '$normalized' (expected 8 valid base32 chars)")
|
||||
Log.w(TAG, "LocationNotesManager rejected an invalid building geohash")
|
||||
return
|
||||
}
|
||||
|
||||
Log.d(TAG, "Setting geohash: $normalized")
|
||||
|
||||
|
||||
// Cancel existing subscription
|
||||
cancel()
|
||||
if (!LiveLocationPrivacyGate.accepts(token)) return
|
||||
liveLocationToken = token
|
||||
|
||||
// Set loading state before clearing to prevent empty state flicker (iOS pattern)
|
||||
_state.value = State.LOADING
|
||||
@ -154,7 +166,7 @@ class LocationNotesManager private constructor() {
|
||||
subscribedGeohashes = (neighbors + normalized).toSet()
|
||||
|
||||
// Start new subscriptions for all cells
|
||||
subscribeAll()
|
||||
subscribeAll(token)
|
||||
}
|
||||
|
||||
/**
|
||||
@ -170,16 +182,20 @@ class LocationNotesManager private constructor() {
|
||||
* Refresh notes for current geohash
|
||||
*/
|
||||
fun refresh() {
|
||||
val token = LiveLocationPrivacyGate.captureToken() ?: run {
|
||||
stop()
|
||||
return
|
||||
}
|
||||
val currentGeohash = _geohash.value
|
||||
if (currentGeohash == null) {
|
||||
Log.w(TAG, "Cannot refresh - no geohash set")
|
||||
return
|
||||
}
|
||||
|
||||
Log.d(TAG, "Refreshing notes for geohash: $currentGeohash")
|
||||
|
||||
// Cancel and restart subscriptions for current ±1 set
|
||||
cancel()
|
||||
if (!LiveLocationPrivacyGate.accepts(token)) return
|
||||
liveLocationToken = token
|
||||
_notes.value = emptyList()
|
||||
noteIDs.clear()
|
||||
_initialLoadComplete.value = false
|
||||
@ -188,13 +204,17 @@ class LocationNotesManager private constructor() {
|
||||
com.bitchat.android.geohash.Geohash.neighborsSamePrecision(currentGeohash)
|
||||
} catch (_: Exception) { emptySet() }
|
||||
subscribedGeohashes = (neighbors + currentGeohash).toSet()
|
||||
subscribeAll()
|
||||
subscribeAll(token)
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a new location note
|
||||
*/
|
||||
fun send(content: String, nickname: String?) {
|
||||
val token = LiveLocationPrivacyGate.captureToken() ?: run {
|
||||
stop()
|
||||
return
|
||||
}
|
||||
val currentGeohash = _geohash.value
|
||||
if (currentGeohash == null) {
|
||||
Log.w(TAG, "Cannot send note - no geohash set")
|
||||
@ -209,16 +229,22 @@ class LocationNotesManager private constructor() {
|
||||
|
||||
// CRITICAL FIX: Get geo-specific relays for sending (matching iOS pattern)
|
||||
// iOS: let relays = dependencies.relayLookup(geohash, TransportConfig.nostrGeoRelayCount)
|
||||
val relays = try {
|
||||
com.bitchat.android.nostr.RelayDirectory.closestRelaysForGeohash(currentGeohash, 5)
|
||||
var relays: List<String> = emptyList()
|
||||
try {
|
||||
LiveLocationPrivacyGate.runIfAllowed(token) {
|
||||
relays = RelayDirectory.closestRelaysForGeohash(currentGeohash, 5)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to lookup relays for geohash $currentGeohash: ${e.message}")
|
||||
emptyList()
|
||||
Log.e(TAG, "Failed to look up location-note relays")
|
||||
}
|
||||
if (!LiveLocationPrivacyGate.accepts(token)) {
|
||||
stop()
|
||||
return
|
||||
}
|
||||
|
||||
// Check if we have relays (iOS pattern: guard !relays.isEmpty())
|
||||
if (relays.isEmpty()) {
|
||||
Log.w(TAG, "Send blocked - no geo relays for geohash: $currentGeohash")
|
||||
Log.w(TAG, "Location-note send blocked because no relays are available")
|
||||
_state.value = State.NO_RELAYS
|
||||
_errorMessage.value = "No relays available"
|
||||
return
|
||||
@ -231,34 +257,40 @@ class LocationNotesManager private constructor() {
|
||||
return
|
||||
}
|
||||
|
||||
Log.d(TAG, "Sending note to geohash: $currentGeohash via ${relays.size} geo relays")
|
||||
|
||||
scope.launch {
|
||||
try {
|
||||
val identity = withContext(Dispatchers.IO) {
|
||||
deriveIdentity(currentGeohash)
|
||||
var identity: NostrIdentity? = null
|
||||
val identityPrepared = withContext(Dispatchers.IO) {
|
||||
LiveLocationPrivacyGate.runIfAllowed(token) {
|
||||
identity = deriveIdentity(currentGeohash)
|
||||
}
|
||||
}
|
||||
|
||||
val event = withContext(Dispatchers.IO) {
|
||||
val preparedIdentity = identity
|
||||
if (!identityPrepared || preparedIdentity == null ||
|
||||
!LiveLocationPrivacyGate.accepts(token)
|
||||
) return@launch
|
||||
|
||||
val preparedEvent = withContext(Dispatchers.IO) {
|
||||
NostrProtocol.createGeohashTextNote(
|
||||
content = trimmed,
|
||||
geohash = currentGeohash,
|
||||
senderIdentity = identity,
|
||||
nickname = nickname
|
||||
)
|
||||
content = trimmed,
|
||||
geohash = currentGeohash,
|
||||
senderIdentity = preparedIdentity,
|
||||
nickname = nickname
|
||||
)
|
||||
}
|
||||
|
||||
if (!LiveLocationPrivacyGate.accepts(token)) return@launch
|
||||
|
||||
// Optimistic local echo - add note immediately to UI
|
||||
val localNote = Note(
|
||||
id = event.id,
|
||||
pubkey = event.pubkey,
|
||||
id = preparedEvent.id,
|
||||
pubkey = preparedEvent.pubkey,
|
||||
content = trimmed,
|
||||
createdAt = event.createdAt,
|
||||
createdAt = preparedEvent.createdAt,
|
||||
nickname = nickname
|
||||
)
|
||||
|
||||
if (!noteIDs.contains(event.id)) {
|
||||
noteIDs.add(event.id)
|
||||
if (!noteIDs.contains(preparedEvent.id)) {
|
||||
noteIDs.add(preparedEvent.id)
|
||||
val currentNotes = _notes.value ?: emptyList()
|
||||
_notes.value = (currentNotes + localNote).sortedByDescending { it.createdAt }
|
||||
|
||||
@ -270,11 +302,12 @@ class LocationNotesManager private constructor() {
|
||||
|
||||
// CRITICAL FIX: Send to geo-specific relays (matching iOS pattern)
|
||||
// iOS: dependencies.sendEvent(event, relays)
|
||||
withContext(Dispatchers.IO) {
|
||||
sendEventFunc?.invoke(event, relays)
|
||||
val sent = withContext(Dispatchers.IO) {
|
||||
LiveLocationPrivacyGate.runIfAllowed(token) {
|
||||
sendEventFunc?.invoke(preparedEvent, relays, token)
|
||||
}
|
||||
}
|
||||
|
||||
Log.d(TAG, "✅ Note sent successfully to ${relays.size} geo relays: ${event.id.take(16)}...")
|
||||
if (!sent) return@launch
|
||||
|
||||
// Clear any error messages on successful send
|
||||
_errorMessage.value = null
|
||||
@ -290,12 +323,16 @@ class LocationNotesManager private constructor() {
|
||||
/**
|
||||
* Subscribe to location notes for current geohash
|
||||
*/
|
||||
private fun subscribeAll() {
|
||||
private fun subscribeAll(token: Long) {
|
||||
subscribeRetryJob?.cancel()
|
||||
subscribeRetryJob = null
|
||||
initialLoadJob?.cancel()
|
||||
initialLoadJob = null
|
||||
|
||||
if (!LiveLocationPrivacyGate.accepts(token)) {
|
||||
stop()
|
||||
return
|
||||
}
|
||||
val currentGeohash = _geohash.value
|
||||
if (currentGeohash == null) {
|
||||
Log.w(TAG, "Cannot subscribe - no geohash set")
|
||||
@ -310,17 +347,20 @@ class LocationNotesManager private constructor() {
|
||||
// Retry a few times in case initialization is racing the sheet open
|
||||
subscribeRetryJob = scope.launch {
|
||||
var attempts = 0
|
||||
while (attempts < 10 && subscribeFunc == null) {
|
||||
while (attempts < 10 &&
|
||||
subscribeFunc == null &&
|
||||
LiveLocationPrivacyGate.accepts(token)
|
||||
) {
|
||||
delay(300)
|
||||
attempts++
|
||||
}
|
||||
val subNow = subscribeFunc
|
||||
if (subNow != null) {
|
||||
if (subNow != null && LiveLocationPrivacyGate.accepts(token)) {
|
||||
// Try again now that dependencies are ready
|
||||
subscribeAll()
|
||||
subscribeAll(token)
|
||||
} else {
|
||||
// Give UI a chance to show empty state rather than spinner forever
|
||||
if (!_initialLoadComplete.value!!) {
|
||||
if (!_initialLoadComplete.value) {
|
||||
_initialLoadComplete.value = true
|
||||
_state.value = State.READY
|
||||
}
|
||||
@ -333,28 +373,33 @@ class LocationNotesManager private constructor() {
|
||||
|
||||
// Subscribe for each geohash in the ±1 set
|
||||
subscribedGeohashes.forEach { gh ->
|
||||
if (!LiveLocationPrivacyGate.accepts(token)) return
|
||||
val filter = NostrFilter.geohashNotes(
|
||||
geohash = gh,
|
||||
since = null,
|
||||
limit = 200
|
||||
)
|
||||
val subId = "location-notes-$gh"
|
||||
Log.d(TAG, "📡 Subscribing to location notes: $subId")
|
||||
val subId = "location-notes-${UUID.randomUUID()}"
|
||||
try {
|
||||
val id = subscribe(filter, subId) { event -> handleEvent(event) }
|
||||
subscriptionIDs[gh] = id
|
||||
var id: String? = null
|
||||
LiveLocationPrivacyGate.runIfAllowed(token) {
|
||||
id = subscribe(filter, subId) { event -> handleEvent(event) }
|
||||
}
|
||||
id?.let { subscriptionIDs[gh] = it }
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to subscribe for $gh: ${e.message}")
|
||||
Log.e(TAG, "Failed to subscribe to location notes")
|
||||
}
|
||||
}
|
||||
|
||||
// Mark initial load complete after brief delay to allow relay responses
|
||||
initialLoadJob = scope.launch {
|
||||
delay(2000) // Wait 2 seconds for initial batch
|
||||
if (_geohash.value == currentGeohash && !_initialLoadComplete.value) {
|
||||
if (_geohash.value == currentGeohash &&
|
||||
LiveLocationPrivacyGate.accepts(token) &&
|
||||
!_initialLoadComplete.value
|
||||
) {
|
||||
_initialLoadComplete.value = true
|
||||
_state.value = State.READY
|
||||
Log.d(TAG, "Initial load complete for geohash: $currentGeohash (${noteIDs.size} notes)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -363,6 +408,9 @@ class LocationNotesManager private constructor() {
|
||||
* Handle incoming event from subscription
|
||||
*/
|
||||
private fun handleEvent(event: NostrEvent) {
|
||||
val token = liveLocationToken
|
||||
if (token == null || !LiveLocationPrivacyGate.accepts(token)) return
|
||||
|
||||
// Validate event
|
||||
if (event.kind != NostrKind.TEXT_NOTE) {
|
||||
Log.v(TAG, "Ignoring non-text-note event: kind=${event.kind}")
|
||||
@ -379,7 +427,6 @@ class LocationNotesManager private constructor() {
|
||||
// Check if matches current geohash
|
||||
val eventGeohash = geohashTag[1]
|
||||
if (!subscribedGeohashes.contains(eventGeohash)) {
|
||||
Log.v(TAG, "Ignoring event for non-subscribed geohash: $eventGeohash")
|
||||
return
|
||||
}
|
||||
|
||||
@ -406,8 +453,6 @@ class LocationNotesManager private constructor() {
|
||||
val currentNotes = _notes.value ?: emptyList()
|
||||
_notes.value = (currentNotes + note).sortedByDescending { it.createdAt }
|
||||
|
||||
Log.d(TAG, "Added note from ${note.displayName}")
|
||||
|
||||
// Trim if exceeds max
|
||||
if (noteIDs.size > MAX_NOTES_IN_MEMORY) {
|
||||
trimOldestNotes()
|
||||
@ -456,7 +501,6 @@ class LocationNotesManager private constructor() {
|
||||
if (subscriptionIDs.isNotEmpty()) {
|
||||
subscriptionIDs.values.forEach { subId ->
|
||||
try {
|
||||
Log.d(TAG, "🚫 Canceling subscription: $subId")
|
||||
unsubscribeFunc?.invoke(subId)
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
@ -473,9 +517,10 @@ class LocationNotesManager private constructor() {
|
||||
*/
|
||||
fun stop() {
|
||||
cancel()
|
||||
liveLocationToken = null
|
||||
_geohash.value = null
|
||||
_notes.value = emptyList()
|
||||
noteIDs.clear()
|
||||
_geohash.value = null
|
||||
_initialLoadComplete.value = false
|
||||
_errorMessage.value = null
|
||||
}
|
||||
|
||||
@ -10,6 +10,47 @@ import java.util.concurrent.Executors
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.math.min
|
||||
|
||||
internal class BitchatNdrRelayAdapter(
|
||||
private val relayManager: NostrRelayManager,
|
||||
private val accountRelayUrls: List<String> = NostrRelayManager.defaultRelays()
|
||||
) : NdrRelayManager {
|
||||
override fun subscribe(
|
||||
filter: NostrFilter,
|
||||
id: String,
|
||||
handler: (NostrEvent) -> Boolean
|
||||
) {
|
||||
relayManager.subscribeAfterSuccessfulProcessing(
|
||||
filter = filter,
|
||||
id = id,
|
||||
targetRelayUrls = accountRelayUrls,
|
||||
handler = handler
|
||||
)
|
||||
}
|
||||
|
||||
override fun unsubscribe(id: String) {
|
||||
relayManager.unsubscribe(id)
|
||||
}
|
||||
|
||||
override fun sendEventConfirmed(
|
||||
event: NostrEvent,
|
||||
completion: (accepted: Boolean) -> Unit
|
||||
) {
|
||||
relayManager.sendEventConfirmed(
|
||||
event = event,
|
||||
relayUrls = accountRelayUrls,
|
||||
completion = completion
|
||||
)
|
||||
}
|
||||
|
||||
override fun cancelConfirmedEvent(eventId: String) {
|
||||
relayManager.cancelConfirmedEvent(eventId)
|
||||
}
|
||||
|
||||
override fun setOnConnectionAvailable(handler: () -> Unit) {
|
||||
relayManager.setNdrConnectionAvailableHandler(handler)
|
||||
}
|
||||
}
|
||||
|
||||
class NdrNostrService(
|
||||
private val relayManager: NdrRelayManager,
|
||||
private val runtimeFactory: NdrPairwiseRuntimeFactory,
|
||||
@ -65,37 +106,8 @@ class NdrNostrService(
|
||||
val storageDirectory = context.filesDir.resolve("ndr")
|
||||
val establishedMarkerDirectory =
|
||||
context.filesDir.resolve("ndr-established-sessions")
|
||||
val relayManager = object : NdrRelayManager {
|
||||
override fun subscribe(
|
||||
filter: NostrFilter,
|
||||
id: String,
|
||||
handler: (NostrEvent) -> Boolean
|
||||
) {
|
||||
NostrRelayManager.getInstance(context)
|
||||
.subscribeAfterSuccessfulProcessing(filter, id, handler)
|
||||
}
|
||||
|
||||
override fun unsubscribe(id: String) {
|
||||
NostrRelayManager.getInstance(context).unsubscribe(id)
|
||||
}
|
||||
|
||||
override fun sendEventConfirmed(
|
||||
event: NostrEvent,
|
||||
completion: (accepted: Boolean) -> Unit
|
||||
) {
|
||||
NostrRelayManager.getInstance(context)
|
||||
.sendEventConfirmed(event, completion = completion)
|
||||
}
|
||||
|
||||
override fun cancelConfirmedEvent(eventId: String) {
|
||||
NostrRelayManager.getInstance(context).cancelConfirmedEvent(eventId)
|
||||
}
|
||||
|
||||
override fun setOnConnectionAvailable(handler: () -> Unit) {
|
||||
NostrRelayManager.getInstance(context)
|
||||
.setNdrConnectionAvailableHandler(handler)
|
||||
}
|
||||
}
|
||||
val relayManager =
|
||||
BitchatNdrRelayAdapter(NostrRelayManager.getInstance(context))
|
||||
|
||||
val runtimeFactory = object : NdrPairwiseRuntimeFactory {
|
||||
override fun newWithStoragePath(
|
||||
|
||||
@ -180,7 +180,7 @@ class NostrClient private constructor(private val context: Context) {
|
||||
|
||||
relayManager.sendEvent(event)
|
||||
|
||||
Log.i(TAG, "📤 Sent geohash message to #$geohash")
|
||||
Log.i(TAG, "📤 Sent geohash message")
|
||||
onSuccess?.invoke()
|
||||
|
||||
} catch (e: Exception) {
|
||||
@ -209,7 +209,7 @@ class NostrClient private constructor(private val context: Context) {
|
||||
}
|
||||
})
|
||||
|
||||
Log.i(TAG, "🌍 Subscribed to geohash channel: #$geohash")
|
||||
Log.i(TAG, "🌍 Subscribed to geohash channel")
|
||||
}
|
||||
|
||||
/**
|
||||
@ -217,7 +217,7 @@ class NostrClient private constructor(private val context: Context) {
|
||||
*/
|
||||
fun unsubscribeFromGeohash(geohash: String) {
|
||||
relayManager.unsubscribe("geohash-$geohash")
|
||||
Log.i(TAG, "Unsubscribed from geohash channel: #$geohash")
|
||||
Log.i(TAG, "Unsubscribed from geohash channel")
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -332,8 +332,12 @@ class NostrDirectMessageHandler(
|
||||
isPrivate = true,
|
||||
recipientNickname = state.getNicknameValue(),
|
||||
senderPeerID = conversationID,
|
||||
senderNostrPubkey = senderPubkey,
|
||||
deliveryStatus =
|
||||
DeliveryStatus.Delivered(to = state.getNicknameValue(), at = Date())
|
||||
DeliveryStatus.Delivered(
|
||||
to = state.getNicknameValue(),
|
||||
at = Date()
|
||||
)
|
||||
)
|
||||
|
||||
val isViewing = state.getSelectedPrivateChatPeerValue() == conversationID
|
||||
@ -435,7 +439,8 @@ class NostrDirectMessageHandler(
|
||||
isRelay = false,
|
||||
isPrivate = true,
|
||||
recipientNickname = state.getNicknameValue(),
|
||||
senderPeerID = conversationID
|
||||
senderPeerID = conversationID,
|
||||
senderNostrPubkey = senderPubkey
|
||||
)
|
||||
}
|
||||
) return NdrDeliveryResult.REJECTED
|
||||
|
||||
@ -6,6 +6,7 @@ import com.bitchat.android.favorites.FavoritesPersistenceService
|
||||
import com.bitchat.android.identity.SecureIdentityStateManager
|
||||
import java.security.MessageDigest
|
||||
import java.security.SecureRandom
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* Manages Nostr identity (secp256k1 keypair) for NIP-17 private messaging
|
||||
@ -100,7 +101,7 @@ object NostrIdentityBridge {
|
||||
private const val DEVICE_SEED_KEY = "nostr_device_seed"
|
||||
|
||||
// Cache for derived geohash identities to avoid repeated crypto operations
|
||||
private val geohashIdentityCache = mutableMapOf<String, NostrIdentity>()
|
||||
private val geohashIdentityCache = ConcurrentHashMap<String, NostrIdentity>()
|
||||
|
||||
/**
|
||||
* Get or create the current Nostr identity
|
||||
@ -157,7 +158,7 @@ object NostrIdentityBridge {
|
||||
// Cache the result for future UI responsiveness
|
||||
geohashIdentityCache[forGeohash] = identity
|
||||
|
||||
Log.d(TAG, "Derived geohash identity for $forGeohash (iteration $i)")
|
||||
Log.d(TAG, "Derived geohash identity")
|
||||
return identity
|
||||
}
|
||||
}
|
||||
@ -172,7 +173,7 @@ object NostrIdentityBridge {
|
||||
// Cache the fallback result too
|
||||
geohashIdentityCache[forGeohash] = fallbackIdentity
|
||||
|
||||
Log.d(TAG, "Used fallback identity derivation for $forGeohash")
|
||||
Log.d(TAG, "Used fallback geohash identity derivation")
|
||||
return fallbackIdentity
|
||||
}
|
||||
|
||||
@ -219,6 +220,10 @@ object NostrIdentityBridge {
|
||||
Log.e(TAG, "Failed to clear Nostr data: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
fun clearGeohashIdentityCache(geohashes: Collection<String>) {
|
||||
geohashes.forEach(geohashIdentityCache::remove)
|
||||
}
|
||||
|
||||
// MARK: - Private Methods
|
||||
|
||||
|
||||
@ -0,0 +1,13 @@
|
||||
package com.bitchat.android.nostr
|
||||
|
||||
internal object NostrLiveSubscriptionPrivacy {
|
||||
fun closeTargets(
|
||||
liveSubscriptionIds: Set<String>,
|
||||
subscriptionsByRelay: Map<String, Set<String>>,
|
||||
): Map<String, Set<String>> = buildMap {
|
||||
subscriptionsByRelay.forEach { (relayUrl, relaySubscriptionIds) ->
|
||||
val matchingIds = relaySubscriptionIds.intersect(liveSubscriptionIds)
|
||||
if (matchingIds.isNotEmpty()) put(relayUrl, matchingIds)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
package com.bitchat.android.nostr
|
||||
|
||||
import android.util.Log
|
||||
import com.bitchat.android.geohash.LiveLocationPrivacyGate
|
||||
import com.google.gson.Gson
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@ -9,6 +10,7 @@ import com.google.gson.JsonArray
|
||||
import com.google.gson.JsonParser
|
||||
import kotlinx.coroutines.*
|
||||
import okhttp3.*
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.math.min
|
||||
@ -108,11 +110,17 @@ class NostrRelayManager internal constructor(
|
||||
val handler: (NostrEvent) -> Unit,
|
||||
val targetRelayUrls: Set<String>? = null, // null means all relays
|
||||
val createdAt: Long = System.currentTimeMillis(),
|
||||
val originGeohash: String? = null // used for logging and grouping
|
||||
val liveLocationToken: Long? = null
|
||||
)
|
||||
|
||||
// Message queue for reliability
|
||||
private val messageQueue = mutableListOf<Pair<NostrEvent, List<String>>>()
|
||||
private data class QueuedEvent(
|
||||
val event: NostrEvent,
|
||||
val targetRelays: List<String>,
|
||||
val liveLocationToken: Long? = null
|
||||
)
|
||||
|
||||
private val messageQueue = mutableListOf<QueuedEvent>()
|
||||
private val messageQueueLock = Any()
|
||||
|
||||
private data class ConfirmedPublish(
|
||||
@ -137,27 +145,49 @@ class NostrRelayManager internal constructor(
|
||||
|
||||
// Per-geohash relay selection
|
||||
private val geohashToRelays = ConcurrentHashMap<String, Set<String>>() // geohash -> relay URLs
|
||||
private val liveGeohashTokens = ConcurrentHashMap<String, Long>()
|
||||
private val liveLocationRelayTokens = ConcurrentHashMap<String, Long>()
|
||||
private val nonLiveRelayUrls = ConcurrentHashMap.newKeySet<String>()
|
||||
private val liveLocationConnectionJobs = ConcurrentHashMap.newKeySet<Job>()
|
||||
|
||||
// --- Public API for geohash-specific operation ---
|
||||
|
||||
/**
|
||||
* Compute and connect to relays for a given geohash (nearest + optional defaults), cache the mapping.
|
||||
*/
|
||||
fun ensureGeohashRelaysConnected(geohash: String, nRelays: Int = 5, includeDefaults: Boolean = false) {
|
||||
fun ensureGeohashRelaysConnected(
|
||||
geohash: String,
|
||||
nRelays: Int = 5,
|
||||
includeDefaults: Boolean = false,
|
||||
liveLocationToken: Long? = null
|
||||
) {
|
||||
if (!isNetworkActionAllowed(liveLocationToken)) return
|
||||
try {
|
||||
val nearest = RelayDirectory.closestRelaysForGeohash(geohash, nRelays)
|
||||
val selected = if (includeDefaults) {
|
||||
(nearest + Companion.defaultRelays()).toSet()
|
||||
} else nearest.toSet()
|
||||
if (selected.isEmpty()) {
|
||||
Log.w(TAG, "No relays selected for geohash=$geohash")
|
||||
Log.w(TAG, "No relays selected for a geohash")
|
||||
return
|
||||
}
|
||||
geohashToRelays[geohash] = selected
|
||||
Log.d(TAG, "Geohash $geohash using ${selected.size} relays")
|
||||
ensureConnectionsFor(selected)
|
||||
runNetworkAction(liveLocationToken) {
|
||||
geohashToRelays[geohash] = selected
|
||||
if (liveLocationToken == null) {
|
||||
liveGeohashTokens.remove(geohash)
|
||||
nonLiveRelayUrls.addAll(selected)
|
||||
} else {
|
||||
liveGeohashTokens[geohash] = liveLocationToken
|
||||
selected.forEach { relayUrl ->
|
||||
if (relayUrl !in nonLiveRelayUrls) {
|
||||
liveLocationRelayTokens[relayUrl] = liveLocationToken
|
||||
}
|
||||
}
|
||||
}
|
||||
ensureConnectionsFor(selected, liveLocationToken)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to ensure relays for $geohash: ${e.message}")
|
||||
Log.e(TAG, "Failed to ensure geohash relays")
|
||||
}
|
||||
}
|
||||
|
||||
@ -177,40 +207,135 @@ class NostrRelayManager internal constructor(
|
||||
id: String = generateSubscriptionId(),
|
||||
handler: (NostrEvent) -> Unit,
|
||||
includeDefaults: Boolean = false,
|
||||
nRelays: Int = 5
|
||||
nRelays: Int = 5,
|
||||
liveLocationToken: Long? = null
|
||||
): String {
|
||||
ensureGeohashRelaysConnected(geohash, nRelays, includeDefaults)
|
||||
if (!isNetworkActionAllowed(liveLocationToken)) return id
|
||||
ensureGeohashRelaysConnected(
|
||||
geohash,
|
||||
nRelays,
|
||||
includeDefaults,
|
||||
liveLocationToken
|
||||
)
|
||||
if (!isNetworkActionAllowed(liveLocationToken)) return id
|
||||
val relayUrls = getRelaysForGeohash(geohash)
|
||||
return subscribe(
|
||||
filter = filter,
|
||||
id = id,
|
||||
handler = handler,
|
||||
targetRelayUrls = relayUrls
|
||||
).also {
|
||||
// update origin geohash for this subscription
|
||||
activeSubscriptions[it]?.let { sub ->
|
||||
activeSubscriptions[it] = sub.copy(originGeohash = geohash)
|
||||
}
|
||||
}
|
||||
targetRelayUrls = relayUrls,
|
||||
liveLocationToken = liveLocationToken
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an event specifically to a geohash's relays (+ optional defaults).
|
||||
*/
|
||||
fun sendEventToGeohash(event: NostrEvent, geohash: String, includeDefaults: Boolean = false, nRelays: Int = 5) {
|
||||
ensureGeohashRelaysConnected(geohash, nRelays, includeDefaults)
|
||||
fun sendEventToGeohash(
|
||||
event: NostrEvent,
|
||||
geohash: String,
|
||||
includeDefaults: Boolean = false,
|
||||
nRelays: Int = 5,
|
||||
liveLocationToken: Long? = null
|
||||
) {
|
||||
if (!isNetworkActionAllowed(liveLocationToken)) return
|
||||
ensureGeohashRelaysConnected(
|
||||
geohash,
|
||||
nRelays,
|
||||
includeDefaults,
|
||||
liveLocationToken
|
||||
)
|
||||
if (!isNetworkActionAllowed(liveLocationToken)) return
|
||||
val relayUrls = getRelaysForGeohash(geohash)
|
||||
if (relayUrls.isEmpty()) {
|
||||
Log.w(TAG, "No target relays to send event for geohash=$geohash; falling back to defaults")
|
||||
sendEvent(event, Companion.defaultRelays())
|
||||
Log.w(TAG, "No target relays for geohash event; falling back to defaults")
|
||||
sendEvent(event, Companion.defaultRelays(), liveLocationToken)
|
||||
return
|
||||
}
|
||||
sendEvent(event, relayUrls)
|
||||
sendEvent(event, relayUrls, liveLocationToken)
|
||||
}
|
||||
|
||||
// --- Internal helpers ---
|
||||
|
||||
private fun ensureConnectionsFor(relayUrls: Set<String>) {
|
||||
private fun isNetworkActionAllowed(liveLocationToken: Long?): Boolean =
|
||||
liveLocationToken == null || LiveLocationPrivacyGate.accepts(liveLocationToken)
|
||||
|
||||
private fun runNetworkAction(
|
||||
liveLocationToken: Long?,
|
||||
action: () -> Unit
|
||||
): Boolean = if (liveLocationToken == null) {
|
||||
action()
|
||||
true
|
||||
} else {
|
||||
LiveLocationPrivacyGate.runIfAllowed(liveLocationToken, action)
|
||||
}
|
||||
|
||||
/**
|
||||
* Privacy teardown is allowed to bypass an already-revoked token solely to stop
|
||||
* server-side delivery. Live subscription IDs are opaque, so CLOSE carries no
|
||||
* geohash. If a CLOSE cannot be queued, fail closed by dropping that socket.
|
||||
*/
|
||||
private fun closeSubscriptionsOnConnectedRelays(subscriptionIds: Set<String>) {
|
||||
if (subscriptionIds.isEmpty()) return
|
||||
|
||||
val closeTargets = NostrLiveSubscriptionPrivacy.closeTargets(
|
||||
liveSubscriptionIds = subscriptionIds,
|
||||
subscriptionsByRelay = subscriptions,
|
||||
)
|
||||
closeTargets.forEach { (relayUrl, relaySubscriptionIds) ->
|
||||
val webSocket = connections[relayUrl] ?: return@forEach
|
||||
relaySubscriptionIds.forEach { subscriptionId ->
|
||||
val request = NostrRequest.Close(subscriptionId)
|
||||
val message = gson.toJson(request, NostrRequest::class.java)
|
||||
val closeQueued = runCatching { webSocket.send(message) }
|
||||
.getOrDefault(false)
|
||||
if (!closeQueued) {
|
||||
connections.remove(relayUrl, webSocket)
|
||||
webSocket.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun revokeLiveLocationAccess() {
|
||||
liveLocationConnectionJobs.forEach(Job::cancel)
|
||||
liveLocationConnectionJobs.clear()
|
||||
|
||||
val liveSubscriptionIds = activeSubscriptions.values
|
||||
.filter { it.liveLocationToken != null }
|
||||
.mapTo(mutableSetOf()) { it.id }
|
||||
closeSubscriptionsOnConnectedRelays(liveSubscriptionIds)
|
||||
liveSubscriptionIds.forEach { id ->
|
||||
activeSubscriptions.remove(id)
|
||||
messageHandlers.remove(id)
|
||||
}
|
||||
subscriptions.replaceAll { _, ids -> ids - liveSubscriptionIds }
|
||||
|
||||
synchronized(messageQueueLock) {
|
||||
messageQueue.removeAll { it.liveLocationToken != null }
|
||||
}
|
||||
|
||||
liveGeohashTokens.keys.forEach(geohashToRelays::remove)
|
||||
liveGeohashTokens.clear()
|
||||
|
||||
val liveOnlyRelayUrls = liveLocationRelayTokens.keys
|
||||
.filterNotTo(mutableSetOf()) { it in nonLiveRelayUrls }
|
||||
liveOnlyRelayUrls.forEach { relayUrl ->
|
||||
connections.remove(relayUrl)?.cancel()
|
||||
}
|
||||
synchronized(relaysList) {
|
||||
relaysList.removeAll { it.url in liveOnlyRelayUrls }
|
||||
}
|
||||
liveLocationRelayTokens.clear()
|
||||
updateRelaysList()
|
||||
updateConnectionStatus()
|
||||
}
|
||||
|
||||
private fun ensureConnectionsFor(
|
||||
relayUrls: Set<String>,
|
||||
liveLocationToken: Long? = null
|
||||
) {
|
||||
if (!isNetworkActionAllowed(liveLocationToken)) return
|
||||
// Ensure relays are tracked for UI/status
|
||||
relayUrls.forEach { url ->
|
||||
if (relaysList.none { it.url == url }) {
|
||||
@ -219,15 +344,22 @@ class NostrRelayManager internal constructor(
|
||||
}
|
||||
updateRelaysList()
|
||||
|
||||
scope.launch {
|
||||
val job = scope.launch {
|
||||
if (!isNetworkActionAllowed(liveLocationToken)) return@launch
|
||||
relayUrls.forEach { relayUrl ->
|
||||
launch {
|
||||
if (!connections.containsKey(relayUrl)) {
|
||||
connectToRelay(relayUrl)
|
||||
if (!connections.containsKey(relayUrl) &&
|
||||
isNetworkActionAllowed(liveLocationToken)
|
||||
) {
|
||||
connectToRelay(relayUrl, liveLocationToken)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (liveLocationToken != null) {
|
||||
liveLocationConnectionJobs.add(job)
|
||||
job.invokeOnCompletion { liveLocationConnectionJobs.remove(job) }
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
@ -240,8 +372,10 @@ class NostrRelayManager internal constructor(
|
||||
"wss://nostr21.com"
|
||||
)
|
||||
relaysList.addAll(defaultRelayUrls.map { Relay(it) })
|
||||
nonLiveRelayUrls.addAll(defaultRelayUrls)
|
||||
_relays.value = relaysList.toList()
|
||||
updateConnectionStatus()
|
||||
LiveLocationPrivacyGate.addRevocationListener(::revokeLiveLocationAccess)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to initialize NostrRelayManager: ${e.message}", e)
|
||||
// Initialize with empty list as fallback
|
||||
@ -254,12 +388,14 @@ class NostrRelayManager internal constructor(
|
||||
* Connect to all configured relays
|
||||
*/
|
||||
fun connect() {
|
||||
Log.i(TAG, "Connecting to ${relaysList.size} Nostr relays")
|
||||
|
||||
scope.launch {
|
||||
relaysList.forEach { relay ->
|
||||
launch {
|
||||
connectToRelay(relay.url)
|
||||
val liveToken = liveLocationRelayTokens[relay.url]
|
||||
?.takeIf { relay.url !in nonLiveRelayUrls }
|
||||
if (liveToken == null || LiveLocationPrivacyGate.accepts(liveToken)) {
|
||||
connectToRelay(relay.url, liveToken)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -272,8 +408,6 @@ class NostrRelayManager internal constructor(
|
||||
* Disconnect from all relays
|
||||
*/
|
||||
fun disconnect() {
|
||||
Log.i(TAG, "Disconnecting from all Nostr relays")
|
||||
|
||||
// Stop subscription validation
|
||||
stopSubscriptionValidation()
|
||||
|
||||
@ -295,23 +429,28 @@ class NostrRelayManager internal constructor(
|
||||
/**
|
||||
* Send an event to specified relays (or all if none specified)
|
||||
*/
|
||||
fun sendEvent(event: NostrEvent, relayUrls: List<String>? = null) {
|
||||
fun sendEvent(
|
||||
event: NostrEvent,
|
||||
relayUrls: List<String>? = null,
|
||||
liveLocationToken: Long? = null
|
||||
) {
|
||||
val targetRelays = relayUrls ?: relaysList.map { it.url }
|
||||
|
||||
// Add to queue for reliability
|
||||
synchronized(messageQueueLock) {
|
||||
messageQueue.add(Pair(event, targetRelays))
|
||||
}
|
||||
|
||||
// Attempt immediate send
|
||||
scope.launch {
|
||||
targetRelays.forEach { relayUrl ->
|
||||
val webSocket = connections[relayUrl]
|
||||
if (webSocket != null) {
|
||||
sendToRelay(event, webSocket, relayUrl)
|
||||
|
||||
val queued = runNetworkAction(liveLocationToken) {
|
||||
synchronized(messageQueueLock) {
|
||||
messageQueue.add(QueuedEvent(event, targetRelays, liveLocationToken))
|
||||
}
|
||||
scope.launch {
|
||||
if (!isNetworkActionAllowed(liveLocationToken)) return@launch
|
||||
targetRelays.forEach { relayUrl ->
|
||||
val webSocket = connections[relayUrl]
|
||||
if (webSocket != null) {
|
||||
sendToRelay(event, webSocket, relayUrl, liveLocationToken)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!queued) return
|
||||
}
|
||||
|
||||
/**
|
||||
@ -389,14 +528,15 @@ class NostrRelayManager internal constructor(
|
||||
filter: NostrFilter,
|
||||
id: String = generateSubscriptionId(),
|
||||
handler: (NostrEvent) -> Unit,
|
||||
targetRelayUrls: List<String>? = null
|
||||
targetRelayUrls: List<String>? = null,
|
||||
liveLocationToken: Long? = null
|
||||
): String {
|
||||
// Store subscription info for persistent tracking
|
||||
val subscriptionInfo = SubscriptionInfo(
|
||||
id = id,
|
||||
filter = filter,
|
||||
handler = handler,
|
||||
targetRelayUrls = targetRelayUrls?.toSet()
|
||||
targetRelayUrls = targetRelayUrls?.toSet(),
|
||||
liveLocationToken = liveLocationToken
|
||||
)
|
||||
return registerSubscription(
|
||||
subscriptionInfo = subscriptionInfo,
|
||||
@ -412,12 +552,14 @@ class NostrRelayManager internal constructor(
|
||||
fun subscribeAfterSuccessfulProcessing(
|
||||
filter: NostrFilter,
|
||||
id: String,
|
||||
targetRelayUrls: List<String>? = null,
|
||||
handler: (NostrEvent) -> Boolean
|
||||
): String {
|
||||
val subscriptionInfo = SubscriptionInfo(
|
||||
id = id,
|
||||
filter = filter,
|
||||
handler = {}
|
||||
handler = {},
|
||||
targetRelayUrls = targetRelayUrls?.toSet()
|
||||
)
|
||||
return registerSubscription(
|
||||
subscriptionInfo = subscriptionInfo,
|
||||
@ -436,15 +578,17 @@ class NostrRelayManager internal constructor(
|
||||
commitAwareHandler: ((NostrEvent) -> Boolean)? = null
|
||||
): String {
|
||||
require((ordinaryHandler == null) != (commitAwareHandler == null))
|
||||
activeSubscriptions[subscriptionInfo.id] = subscriptionInfo
|
||||
if (commitAwareHandler != null) {
|
||||
commitAwareMessageHandlers[subscriptionInfo.id] = commitAwareHandler
|
||||
messageHandlers.remove(subscriptionInfo.id)
|
||||
} else {
|
||||
messageHandlers[subscriptionInfo.id] = requireNotNull(ordinaryHandler)
|
||||
commitAwareMessageHandlers.remove(subscriptionInfo.id)
|
||||
runNetworkAction(subscriptionInfo.liveLocationToken) {
|
||||
activeSubscriptions[subscriptionInfo.id] = subscriptionInfo
|
||||
if (commitAwareHandler != null) {
|
||||
commitAwareMessageHandlers[subscriptionInfo.id] = commitAwareHandler
|
||||
messageHandlers.remove(subscriptionInfo.id)
|
||||
} else {
|
||||
messageHandlers[subscriptionInfo.id] = requireNotNull(ordinaryHandler)
|
||||
commitAwareMessageHandlers.remove(subscriptionInfo.id)
|
||||
}
|
||||
sendSubscriptionToRelays(subscriptionInfo)
|
||||
}
|
||||
sendSubscriptionToRelays(subscriptionInfo)
|
||||
return subscriptionInfo.id
|
||||
}
|
||||
|
||||
@ -452,32 +596,38 @@ class NostrRelayManager internal constructor(
|
||||
* Send a subscription to the appropriate relays
|
||||
*/
|
||||
private fun sendSubscriptionToRelays(subscriptionInfo: SubscriptionInfo) {
|
||||
if (!isNetworkActionAllowed(subscriptionInfo.liveLocationToken)) return
|
||||
val request = NostrRequest.Subscribe(subscriptionInfo.id, listOf(subscriptionInfo.filter))
|
||||
val message = gson.toJson(request, NostrRequest::class.java)
|
||||
|
||||
scope.launch {
|
||||
if (!isNetworkActionAllowed(subscriptionInfo.liveLocationToken)) return@launch
|
||||
val targetRelays = subscriptionInfo.targetRelayUrls?.toList() ?: connections.keys.toList()
|
||||
|
||||
targetRelays.forEach { relayUrl ->
|
||||
val webSocket = connections[relayUrl]
|
||||
if (webSocket != null) {
|
||||
try {
|
||||
val success = webSocket.send(message)
|
||||
if (success) {
|
||||
// Track subscription for this relay
|
||||
val currentSubs = subscriptions[relayUrl] ?: emptySet()
|
||||
subscriptions[relayUrl] = currentSubs + subscriptionInfo.id
|
||||
} else {
|
||||
Log.w(TAG, "Failed to send subscription to $relayUrl: WebSocket send failed")
|
||||
var success = false
|
||||
runNetworkAction(subscriptionInfo.liveLocationToken) {
|
||||
success = webSocket.send(message)
|
||||
if (success) {
|
||||
val currentSubs = subscriptions[relayUrl] ?: emptySet()
|
||||
subscriptions[relayUrl] =
|
||||
currentSubs + subscriptionInfo.id
|
||||
}
|
||||
}
|
||||
if (!success) {
|
||||
Log.w(TAG, "Failed to send subscription: WebSocket send failed")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to send subscription to $relayUrl: ${e.message}")
|
||||
Log.e(TAG, "Failed to send subscription")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (connections.isEmpty()) {
|
||||
Log.w(TAG, "No relay connections available for subscription, will retry on reconnection")
|
||||
Log.w(TAG, "⚠️ No relay connections available for subscription, will retry on reconnection")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -492,7 +642,14 @@ class NostrRelayManager internal constructor(
|
||||
commitAwareMessageHandlers.remove(id)
|
||||
|
||||
if (subscriptionInfo == null) {
|
||||
Log.w(TAG, "Attempted to unsubscribe from unknown subscription: $id")
|
||||
return
|
||||
}
|
||||
|
||||
if (subscriptionInfo.liveLocationToken != null &&
|
||||
!isNetworkActionAllowed(subscriptionInfo.liveLocationToken)
|
||||
) {
|
||||
closeSubscriptionsOnConnectedRelays(setOf(id))
|
||||
subscriptions.replaceAll { _, ids -> ids - id }
|
||||
return
|
||||
}
|
||||
|
||||
@ -500,14 +657,21 @@ class NostrRelayManager internal constructor(
|
||||
val message = gson.toJson(request, NostrRequest::class.java)
|
||||
|
||||
scope.launch {
|
||||
if (!isNetworkActionAllowed(subscriptionInfo.liveLocationToken)) {
|
||||
closeSubscriptionsOnConnectedRelays(setOf(id))
|
||||
subscriptions.replaceAll { _, ids -> ids - id }
|
||||
return@launch
|
||||
}
|
||||
connections.forEach { (relayUrl, webSocket) ->
|
||||
val currentSubs = subscriptions[relayUrl]
|
||||
if (currentSubs?.contains(id) == true) {
|
||||
try {
|
||||
webSocket.send(message)
|
||||
runNetworkAction(subscriptionInfo.liveLocationToken) {
|
||||
webSocket.send(message)
|
||||
}
|
||||
subscriptions[relayUrl] = currentSubs - id
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to unsubscribe from $relayUrl: ${e.message}")
|
||||
Log.e(TAG, "Failed to unsubscribe from relay")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -519,6 +683,9 @@ class NostrRelayManager internal constructor(
|
||||
*/
|
||||
fun retryConnection(relayUrl: String) {
|
||||
val relay = relaysList.find { it.url == relayUrl } ?: return
|
||||
val liveToken = liveLocationRelayTokens[relayUrl]
|
||||
?.takeIf { relayUrl !in nonLiveRelayUrls }
|
||||
if (!isNetworkActionAllowed(liveToken)) return
|
||||
|
||||
// Reset reconnection attempts
|
||||
relay.reconnectAttempts = 0
|
||||
@ -530,7 +697,7 @@ class NostrRelayManager internal constructor(
|
||||
|
||||
// Attempt immediate reconnection
|
||||
scope.launch {
|
||||
connectToRelay(relayUrl)
|
||||
connectToRelay(relayUrl, liveToken)
|
||||
}
|
||||
}
|
||||
|
||||
@ -680,7 +847,7 @@ class NostrRelayManager internal constructor(
|
||||
try {
|
||||
val report = validateSubscriptionConsistency()
|
||||
if (!report.isConsistent && report.connectedRelayCount > 0) {
|
||||
Log.w(TAG, "Subscription inconsistencies detected: ${report.inconsistencies}")
|
||||
Log.w(TAG, "Nostr subscription inconsistencies detected")
|
||||
|
||||
// Auto-repair: re-establish subscriptions for relays with missing ones
|
||||
connections.forEach { (relayUrl, webSocket) ->
|
||||
@ -692,7 +859,7 @@ class NostrRelayManager internal constructor(
|
||||
|
||||
val missingSubs = expectedSubs - currentSubs
|
||||
if (missingSubs.isNotEmpty()) {
|
||||
Log.i(TAG, "Auto-repairing ${missingSubs.size} missing subscriptions for $relayUrl")
|
||||
Log.i(TAG, "Auto-repairing ${missingSubs.size} missing subscriptions")
|
||||
restoreSubscriptionsForRelay(relayUrl, webSocket)
|
||||
}
|
||||
}
|
||||
@ -702,6 +869,7 @@ class NostrRelayManager internal constructor(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
@ -714,7 +882,13 @@ class NostrRelayManager internal constructor(
|
||||
|
||||
// MARK: - Private Methods
|
||||
|
||||
private suspend fun connectToRelay(urlString: String) {
|
||||
private suspend fun connectToRelay(
|
||||
urlString: String,
|
||||
liveLocationToken: Long? = null
|
||||
) {
|
||||
val connectionToken = liveLocationToken
|
||||
?.takeIf { urlString !in nonLiveRelayUrls }
|
||||
if (!isNetworkActionAllowed(connectionToken)) return
|
||||
// Skip if we already have a connection
|
||||
if (connections.containsKey(urlString)) {
|
||||
return
|
||||
@ -725,21 +899,35 @@ class NostrRelayManager internal constructor(
|
||||
.url(urlString)
|
||||
.build()
|
||||
|
||||
val webSocket = httpClient.newWebSocket(request, RelayWebSocketListener(urlString))
|
||||
connections[urlString] = webSocket
|
||||
runNetworkAction(connectionToken) {
|
||||
val webSocket = httpClient.newWebSocket(
|
||||
request,
|
||||
RelayWebSocketListener(urlString, connectionToken)
|
||||
)
|
||||
connections[urlString] = webSocket
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to create WebSocket connection to $urlString: ${e.message}")
|
||||
handleDisconnection(urlString, e)
|
||||
Log.e(TAG, "Failed to create WebSocket connection")
|
||||
handleDisconnection(urlString, e, liveLocationToken)
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendToRelay(event: NostrEvent, webSocket: WebSocket, relayUrl: String): Boolean {
|
||||
private fun sendToRelay(
|
||||
event: NostrEvent,
|
||||
webSocket: WebSocket,
|
||||
relayUrl: String,
|
||||
liveLocationToken: Long? = null
|
||||
): Boolean {
|
||||
if (!isNetworkActionAllowed(liveLocationToken)) return false
|
||||
return try {
|
||||
val request = NostrRequest.Event(event)
|
||||
val message = gson.toJson(request, NostrRequest::class.java)
|
||||
|
||||
val success = webSocket.send(message)
|
||||
var success = false
|
||||
runNetworkAction(liveLocationToken) {
|
||||
success = webSocket.send(message)
|
||||
}
|
||||
if (success) {
|
||||
// Update relay stats
|
||||
val relay = relaysList.find { it.url == relayUrl }
|
||||
@ -760,7 +948,7 @@ class NostrRelayManager internal constructor(
|
||||
try {
|
||||
val jsonElement = JsonParser.parseString(message)
|
||||
if (!jsonElement.isJsonArray) {
|
||||
Log.w(TAG, "Received non-array message from $relayUrl")
|
||||
Log.w(TAG, "Received non-array message from relay")
|
||||
return
|
||||
}
|
||||
|
||||
@ -774,7 +962,10 @@ class NostrRelayManager internal constructor(
|
||||
updateRelaysList()
|
||||
|
||||
// CLIENT-SIDE FILTER ENFORCEMENT: Ensure this event matches the subscription's filter
|
||||
activeSubscriptions[response.subscriptionId]?.let { subInfo ->
|
||||
val subscriptionInfo = activeSubscriptions[response.subscriptionId]
|
||||
?: return
|
||||
if (!isNetworkActionAllowed(subscriptionInfo.liveLocationToken)) return
|
||||
subscriptionInfo.let { subInfo ->
|
||||
val matches = try { subInfo.filter.matches(response.event) } catch (e: Exception) { true }
|
||||
if (!matches) {
|
||||
// Do NOT call deduplicator here to allow the correct subscription to process it later
|
||||
@ -799,12 +990,15 @@ class NostrRelayManager internal constructor(
|
||||
val handler = messageHandlers[response.subscriptionId]
|
||||
if (handler != null) {
|
||||
scope.launch(Dispatchers.Main) {
|
||||
handler(event)
|
||||
if (isNetworkActionAllowed(subscriptionInfo.liveLocationToken)) {
|
||||
handler(event)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Log.d(TAG, "No handler for subscription ${response.subscriptionId}")
|
||||
Log.w(TAG, "⚠️ No handler for Nostr subscription")
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
is NostrResponse.EndOfStoredEvents -> {
|
||||
@ -824,24 +1018,30 @@ class NostrRelayManager internal constructor(
|
||||
}
|
||||
if (!isNip20ConfirmedSuccess(response.accepted, response.message)) {
|
||||
val level = if (wasGiftWrap) Log.WARN else Log.ERROR
|
||||
Log.println(level, TAG, "Event rejected by relay $relayUrl: ${response.message ?: "no reason"}")
|
||||
Log.println(level, TAG, "Event rejected by relay: ${response.message ?: "no reason"}")
|
||||
}
|
||||
}
|
||||
|
||||
is NostrResponse.Notice -> {
|
||||
Log.d(TAG, "Notice from $relayUrl: ${response.message}")
|
||||
// No action needed
|
||||
}
|
||||
|
||||
is NostrResponse.Unknown -> {
|
||||
Log.d(TAG, "Unknown message type from $relayUrl")
|
||||
// No action needed
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to parse message from $relayUrl: ${e.message}")
|
||||
Log.e(TAG, "Failed to parse relay message")
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleDisconnection(relayUrl: String, error: Throwable) {
|
||||
private fun handleDisconnection(
|
||||
relayUrl: String,
|
||||
error: Throwable,
|
||||
liveLocationToken: Long? = null
|
||||
) {
|
||||
val connectionToken = liveLocationToken
|
||||
?.takeIf { relayUrl !in nonLiveRelayUrls }
|
||||
connections.remove(relayUrl)
|
||||
confirmedPublishes.entries.toList().forEach { (eventId, tracker) ->
|
||||
if (tracker.awaitingRelayUrls.remove(relayUrl) &&
|
||||
@ -854,6 +1054,7 @@ class NostrRelayManager internal constructor(
|
||||
// subscriptions.remove(relayUrl) // REMOVED - this was causing subscription loss
|
||||
|
||||
updateRelayStatus(relayUrl, false, error)
|
||||
if (!isNetworkActionAllowed(connectionToken)) return
|
||||
|
||||
// Check if this is a DNS error
|
||||
val errorMessage = error.message?.lowercase() ?: ""
|
||||
@ -863,7 +1064,7 @@ class NostrRelayManager internal constructor(
|
||||
|
||||
val relay = relaysList.find { it.url == relayUrl }
|
||||
if (relay?.lastError == null) {
|
||||
Log.w(TAG, "Nostr relay DNS failure for $relayUrl - not retrying")
|
||||
Log.w(TAG, "Nostr relay DNS failure; not retrying")
|
||||
}
|
||||
return
|
||||
}
|
||||
@ -874,7 +1075,7 @@ class NostrRelayManager internal constructor(
|
||||
|
||||
// Stop attempting after max attempts
|
||||
if (relay.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
|
||||
Log.w(TAG, "Max reconnection attempts ($MAX_RECONNECT_ATTEMPTS) reached for $relayUrl")
|
||||
Log.w(TAG, "Max Nostr relay reconnection attempts reached")
|
||||
return
|
||||
}
|
||||
|
||||
@ -886,12 +1087,14 @@ class NostrRelayManager internal constructor(
|
||||
|
||||
relay.nextReconnectTime = System.currentTimeMillis() + backoffInterval
|
||||
|
||||
Log.d(TAG, "Scheduling reconnection to $relayUrl in ${backoffInterval / 1000}s (attempt ${relay.reconnectAttempts})")
|
||||
Log.d(TAG, "Scheduling Nostr relay reconnection")
|
||||
|
||||
// Schedule reconnection
|
||||
scope.launch {
|
||||
delay(backoffInterval)
|
||||
connectToRelay(relayUrl)
|
||||
if (isNetworkActionAllowed(connectionToken)) {
|
||||
connectToRelay(relayUrl, connectionToken)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -923,7 +1126,7 @@ class NostrRelayManager internal constructor(
|
||||
}
|
||||
|
||||
private fun generateSubscriptionId(): String {
|
||||
return "sub-${System.currentTimeMillis()}-${(Math.random() * 1000).toInt()}"
|
||||
return "sub-${UUID.randomUUID()}"
|
||||
}
|
||||
|
||||
/**
|
||||
@ -932,30 +1135,34 @@ class NostrRelayManager internal constructor(
|
||||
private fun restoreSubscriptionsForRelay(relayUrl: String, webSocket: WebSocket) {
|
||||
val subscriptionsToRestore = activeSubscriptions.values.filter { subscriptionInfo ->
|
||||
// Include subscription if it targets all relays or specifically targets this relay
|
||||
subscriptionInfo.targetRelayUrls == null || subscriptionInfo.targetRelayUrls.contains(relayUrl)
|
||||
isNetworkActionAllowed(subscriptionInfo.liveLocationToken) &&
|
||||
(subscriptionInfo.targetRelayUrls == null ||
|
||||
subscriptionInfo.targetRelayUrls.contains(relayUrl))
|
||||
}
|
||||
|
||||
if (subscriptionsToRestore.isEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
Log.d(TAG, "Restoring ${subscriptionsToRestore.size} subscriptions for relay: $relayUrl")
|
||||
|
||||
subscriptionsToRestore.forEach { subscriptionInfo ->
|
||||
try {
|
||||
val request = NostrRequest.Subscribe(subscriptionInfo.id, listOf(subscriptionInfo.filter))
|
||||
val message = gson.toJson(request, NostrRequest::class.java)
|
||||
|
||||
val success = webSocket.send(message)
|
||||
if (success) {
|
||||
// Track subscription for this relay
|
||||
val currentSubs = subscriptions[relayUrl] ?: emptySet()
|
||||
subscriptions[relayUrl] = currentSubs + subscriptionInfo.id
|
||||
} else {
|
||||
Log.w(TAG, "Failed to restore subscription '${subscriptionInfo.id}' to $relayUrl: WebSocket send failed")
|
||||
var success = false
|
||||
runNetworkAction(subscriptionInfo.liveLocationToken) {
|
||||
success = webSocket.send(message)
|
||||
if (success) {
|
||||
val currentSubs = subscriptions[relayUrl] ?: emptySet()
|
||||
subscriptions[relayUrl] =
|
||||
currentSubs + subscriptionInfo.id
|
||||
}
|
||||
}
|
||||
if (!success) {
|
||||
Log.w(TAG, "Failed to restore subscription: WebSocket send failed")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to restore subscription '${subscriptionInfo.id}' to $relayUrl: ${e.message}")
|
||||
Log.e(TAG, "Failed to restore subscription")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -963,10 +1170,17 @@ class NostrRelayManager internal constructor(
|
||||
/**
|
||||
* WebSocket listener for relay connections
|
||||
*/
|
||||
private inner class RelayWebSocketListener(private val relayUrl: String) : WebSocketListener() {
|
||||
private inner class RelayWebSocketListener(
|
||||
private val relayUrl: String,
|
||||
private val liveLocationToken: Long?
|
||||
) : WebSocketListener() {
|
||||
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
Log.i(TAG, "Connected to Nostr relay: $relayUrl")
|
||||
if (!isNetworkActionAllowed(liveLocationToken)) {
|
||||
connections.remove(relayUrl)
|
||||
webSocket.cancel()
|
||||
return
|
||||
}
|
||||
updateRelayStatus(relayUrl, true)
|
||||
runCatching { ndrConnectionAvailableHandler?.invoke() }
|
||||
.onFailure { Log.w(TAG, "NDR reconnect callback failed") }
|
||||
@ -978,9 +1192,16 @@ class NostrRelayManager internal constructor(
|
||||
synchronized(messageQueueLock) {
|
||||
val iterator = messageQueue.iterator()
|
||||
while (iterator.hasNext()) {
|
||||
val (event, targetRelays) = iterator.next()
|
||||
if (relayUrl in targetRelays) {
|
||||
sendToRelay(event, webSocket, relayUrl)
|
||||
val queued = iterator.next()
|
||||
if (relayUrl in queued.targetRelays &&
|
||||
isNetworkActionAllowed(queued.liveLocationToken)
|
||||
) {
|
||||
sendToRelay(
|
||||
queued.event,
|
||||
webSocket,
|
||||
relayUrl,
|
||||
queued.liveLocationToken
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -995,14 +1216,13 @@ class NostrRelayManager internal constructor(
|
||||
}
|
||||
|
||||
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||
Log.i(TAG, "Disconnected from Nostr relay $relayUrl: $code $reason")
|
||||
val error = Exception("WebSocket closed: $code $reason")
|
||||
handleDisconnection(relayUrl, error)
|
||||
handleDisconnection(relayUrl, error, liveLocationToken)
|
||||
}
|
||||
|
||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
||||
Log.e(TAG, "WebSocket failure for $relayUrl: ${t.message}")
|
||||
handleDisconnection(relayUrl, t)
|
||||
Log.e(TAG, "Nostr WebSocket failure")
|
||||
handleDisconnection(relayUrl, t, liveLocationToken)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ package com.bitchat.android.nostr
|
||||
|
||||
import android.app.Application
|
||||
import android.util.Log
|
||||
import com.bitchat.android.geohash.LiveLocationPrivacyGate
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@ -28,18 +29,54 @@ class NostrSubscriptionManager(
|
||||
}
|
||||
|
||||
/** Subscribe to geohash chat messages only (kind 20000) — low-volume, kept alive in background. */
|
||||
fun subscribeGeohashMessages(geohash: String, sinceMs: Long, limit: Int, id: String, handler: (NostrEvent) -> Unit) {
|
||||
fun subscribeGeohashMessages(
|
||||
geohash: String,
|
||||
sinceMs: Long,
|
||||
limit: Int,
|
||||
id: String,
|
||||
handler: (NostrEvent) -> Unit,
|
||||
liveLocationToken: Long? = null
|
||||
) {
|
||||
scope.launch {
|
||||
if (liveLocationToken != null &&
|
||||
!LiveLocationPrivacyGate.accepts(liveLocationToken)
|
||||
) return@launch
|
||||
val filter = NostrFilter.geohashMessages(geohash, sinceMs, limit)
|
||||
relayManager.subscribeForGeohash(geohash, filter, id, handler, includeDefaults = false, nRelays = 5)
|
||||
relayManager.subscribeForGeohash(
|
||||
geohash,
|
||||
filter,
|
||||
id,
|
||||
handler,
|
||||
includeDefaults = false,
|
||||
nRelays = 5,
|
||||
liveLocationToken = liveLocationToken
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Subscribe to geohash presence heartbeats only (kind 20001) — high-volume, paused in background. */
|
||||
fun subscribeGeohashPresence(geohash: String, sinceMs: Long, limit: Int, id: String, handler: (NostrEvent) -> Unit) {
|
||||
fun subscribeGeohashPresence(
|
||||
geohash: String,
|
||||
sinceMs: Long,
|
||||
limit: Int,
|
||||
id: String,
|
||||
handler: (NostrEvent) -> Unit,
|
||||
liveLocationToken: Long? = null
|
||||
) {
|
||||
scope.launch {
|
||||
if (liveLocationToken != null &&
|
||||
!LiveLocationPrivacyGate.accepts(liveLocationToken)
|
||||
) return@launch
|
||||
val filter = NostrFilter.geohashPresence(geohash, sinceMs, limit)
|
||||
relayManager.subscribeForGeohash(geohash, filter, id, handler, includeDefaults = false, nRelays = 5)
|
||||
relayManager.subscribeForGeohash(
|
||||
geohash,
|
||||
filter,
|
||||
id,
|
||||
handler,
|
||||
includeDefaults = false,
|
||||
nRelays = 5,
|
||||
liveLocationToken = liveLocationToken
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -92,7 +92,7 @@ object RelayDirectory {
|
||||
val c = com.bitchat.android.geohash.Geohash.decodeToCenter(geohash)
|
||||
c
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to decode geohash '$geohash': ${e.message}")
|
||||
Log.e(TAG, "Failed to decode geohash")
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
@ -300,4 +300,3 @@ object RelayDirectory {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,5 +1,8 @@
|
||||
package com.bitchat.android.onboarding
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.LocationOn
|
||||
import androidx.compose.material.icons.filled.Security
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@ -13,9 +16,6 @@ import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.LocationOn
|
||||
import androidx.compose.material.icons.filled.Security
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.ColorScheme
|
||||
@ -28,11 +28,11 @@ import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.bitchat.android.ui.theme.BitchatFontFamily
|
||||
import com.bitchat.android.R
|
||||
|
||||
/**
|
||||
@ -99,7 +99,7 @@ fun BackgroundLocationPermissionScreen(
|
||||
Text(
|
||||
text = stringResource(R.string.background_location_settings_tip),
|
||||
style = MaterialTheme.typography.bodySmall.copy(
|
||||
fontFamily = FontFamily.Monospace
|
||||
fontFamily = BitchatFontFamily
|
||||
),
|
||||
color = colorScheme.onBackground.copy(alpha = 0.8f)
|
||||
)
|
||||
@ -140,14 +140,14 @@ fun BackgroundLocationPermissionScreen(
|
||||
Text(
|
||||
text = stringResource(R.string.background_location_needs_bullets),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
color = colorScheme.onBackground.copy(alpha = 0.8f)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.background_location_privacy_note),
|
||||
style = MaterialTheme.typography.bodySmall.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontWeight = FontWeight.Medium
|
||||
),
|
||||
color = colorScheme.onBackground
|
||||
@ -183,7 +183,7 @@ fun BackgroundLocationPermissionScreen(
|
||||
Text(
|
||||
text = stringResource(R.string.grant_background_location),
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontWeight = FontWeight.Bold
|
||||
),
|
||||
modifier = Modifier.padding(vertical = 4.dp)
|
||||
@ -201,7 +201,7 @@ fun BackgroundLocationPermissionScreen(
|
||||
Text(
|
||||
text = stringResource(R.string.check_again),
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace
|
||||
fontFamily = BitchatFontFamily
|
||||
)
|
||||
)
|
||||
}
|
||||
@ -213,7 +213,7 @@ fun BackgroundLocationPermissionScreen(
|
||||
Text(
|
||||
text = stringResource(R.string.battery_optimization_skip),
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace
|
||||
fontFamily = BitchatFontFamily
|
||||
)
|
||||
)
|
||||
}
|
||||
@ -234,7 +234,7 @@ private fun HeaderSection(colorScheme: ColorScheme) {
|
||||
Text(
|
||||
text = stringResource(R.string.app_name),
|
||||
style = MaterialTheme.typography.headlineLarge.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 32.sp
|
||||
),
|
||||
@ -244,7 +244,7 @@ private fun HeaderSection(colorScheme: ColorScheme) {
|
||||
Text(
|
||||
text = stringResource(R.string.background_location_required_subtitle),
|
||||
fontSize = 12.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
color = colorScheme.onBackground.copy(alpha = 0.7f)
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,13 +1,13 @@
|
||||
package com.bitchat.android.onboarding
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
import androidx.compose.animation.core.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
@ -15,11 +15,11 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.rotate
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.bitchat.android.ui.theme.BitchatFontFamily
|
||||
import com.bitchat.android.R
|
||||
|
||||
/**
|
||||
@ -105,7 +105,7 @@ private fun BatteryOptimizationEnabledContent(
|
||||
Text(
|
||||
text = stringResource(R.string.app_name),
|
||||
style = MaterialTheme.typography.headlineLarge.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 32.sp
|
||||
),
|
||||
@ -115,7 +115,7 @@ private fun BatteryOptimizationEnabledContent(
|
||||
Text(
|
||||
text = stringResource(R.string.battery_optimization_detected_title),
|
||||
fontSize = 12.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
color = colorScheme.onBackground.copy(alpha = 0.7f)
|
||||
)
|
||||
}
|
||||
@ -225,7 +225,7 @@ private fun BatteryOptimizationEnabledContent(
|
||||
Text(
|
||||
text = stringResource(R.string.disable_battery_optimization),
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
)
|
||||
@ -243,7 +243,7 @@ private fun BatteryOptimizationEnabledContent(
|
||||
Text(
|
||||
text = stringResource(R.string.check_again),
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace
|
||||
fontFamily = BitchatFontFamily
|
||||
)
|
||||
)
|
||||
}
|
||||
@ -259,7 +259,7 @@ private fun BatteryOptimizationEnabledContent(
|
||||
Text(
|
||||
text = stringResource(R.string.battery_optimization_skip),
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace
|
||||
fontFamily = BitchatFontFamily
|
||||
)
|
||||
)
|
||||
}
|
||||
@ -284,7 +284,7 @@ private fun BatteryOptimizationCheckingContent(
|
||||
Text(
|
||||
text = stringResource(R.string.app_name),
|
||||
style = MaterialTheme.typography.headlineLarge.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 32.sp
|
||||
),
|
||||
@ -294,7 +294,7 @@ private fun BatteryOptimizationCheckingContent(
|
||||
Text(
|
||||
text = stringResource(R.string.battery_optimization_disabled_title),
|
||||
fontSize = 12.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
color = colorScheme.onBackground.copy(alpha = 0.7f)
|
||||
)
|
||||
}
|
||||
@ -322,7 +322,7 @@ private fun BatteryOptimizationCheckingContent(
|
||||
Text(
|
||||
text = stringResource(R.string.battery_optimization_success_message),
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
color = colorScheme.onBackground.copy(alpha = 0.8f)
|
||||
),
|
||||
textAlign = TextAlign.Center
|
||||
@ -347,7 +347,7 @@ private fun BatteryOptimizationNotSupportedContent(
|
||||
Text(
|
||||
text = stringResource(R.string.app_name),
|
||||
style = MaterialTheme.typography.headlineLarge.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 32.sp
|
||||
),
|
||||
@ -357,7 +357,7 @@ private fun BatteryOptimizationNotSupportedContent(
|
||||
Text(
|
||||
text = stringResource(R.string.battery_optimization_not_required),
|
||||
fontSize = 12.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
color = colorScheme.onBackground.copy(alpha = 0.7f)
|
||||
)
|
||||
}
|
||||
@ -372,7 +372,7 @@ private fun BatteryOptimizationNotSupportedContent(
|
||||
Text(
|
||||
text = stringResource(R.string.battery_optimization_not_supported_message),
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
color = colorScheme.onBackground.copy(alpha = 0.8f)
|
||||
),
|
||||
textAlign = TextAlign.Center
|
||||
@ -388,7 +388,7 @@ private fun BatteryOptimizationNotSupportedContent(
|
||||
Text(
|
||||
text = stringResource(R.string.continue_btn),
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
)
|
||||
|
||||
@ -1,20 +1,20 @@
|
||||
package com.bitchat.android.onboarding
|
||||
|
||||
import androidx.compose.animation.core.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
import androidx.compose.animation.core.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.rotate
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.bitchat.android.ui.theme.BitchatFontFamily
|
||||
import com.bitchat.android.R
|
||||
|
||||
/**
|
||||
@ -83,7 +83,7 @@ private fun BluetoothDisabledContent(
|
||||
Text(
|
||||
text = stringResource(R.string.bluetooth_recommended),
|
||||
style = MaterialTheme.typography.headlineSmall.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = colorScheme.primary
|
||||
),
|
||||
@ -114,7 +114,7 @@ private fun BluetoothDisabledContent(
|
||||
Text(
|
||||
text = stringResource(R.string.bluetooth_needs_bullets),
|
||||
style = MaterialTheme.typography.bodySmall.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
color = colorScheme.onSurface.copy(alpha = 0.8f)
|
||||
)
|
||||
)
|
||||
@ -138,7 +138,7 @@ private fun BluetoothDisabledContent(
|
||||
Text(
|
||||
text = stringResource(R.string.enable_bluetooth),
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontWeight = FontWeight.Bold
|
||||
),
|
||||
modifier = Modifier.padding(vertical = 4.dp)
|
||||
@ -187,7 +187,7 @@ private fun BluetoothNotSupportedContent(
|
||||
Text(
|
||||
text = stringResource(R.string.bluetooth_not_supported),
|
||||
style = MaterialTheme.typography.headlineSmall.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = colorScheme.error
|
||||
),
|
||||
@ -204,7 +204,7 @@ private fun BluetoothNotSupportedContent(
|
||||
Text(
|
||||
text = stringResource(R.string.bluetooth_unsupported_explanation),
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
color = colorScheme.onSurface
|
||||
),
|
||||
modifier = Modifier.padding(16.dp),
|
||||
@ -235,7 +235,7 @@ private fun BluetoothCheckingContent(
|
||||
Text(
|
||||
text = stringResource(R.string.app_name),
|
||||
style = MaterialTheme.typography.headlineLarge.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = colorScheme.primary
|
||||
),
|
||||
@ -247,7 +247,7 @@ private fun BluetoothCheckingContent(
|
||||
Text(
|
||||
text = stringResource(R.string.checking_bluetooth_status),
|
||||
style = MaterialTheme.typography.bodyLarge.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
color = colorScheme.onSurface.copy(alpha = 0.7f)
|
||||
)
|
||||
)
|
||||
|
||||
@ -8,11 +8,11 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.rotate
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.bitchat.android.ui.theme.BitchatFontFamily
|
||||
import com.bitchat.android.R
|
||||
|
||||
/**
|
||||
@ -63,7 +63,7 @@ fun InitializingScreen(modifier: Modifier) {
|
||||
Text(
|
||||
text = stringResource(R.string.app_name),
|
||||
style = MaterialTheme.typography.headlineLarge.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = colorScheme.primary
|
||||
),
|
||||
@ -92,7 +92,7 @@ fun InitializingScreen(modifier: Modifier) {
|
||||
Text(
|
||||
text = stringResource(R.string.initializing_mesh_network),
|
||||
style = MaterialTheme.typography.bodyLarge.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
color = colorScheme.onSurface.copy(alpha = 0.7f)
|
||||
)
|
||||
)
|
||||
@ -102,7 +102,7 @@ fun InitializingScreen(modifier: Modifier) {
|
||||
Text(
|
||||
text = stringResource(R.string.dot),
|
||||
style = MaterialTheme.typography.bodyLarge.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
color = colorScheme.onSurface.copy(alpha = alpha)
|
||||
)
|
||||
)
|
||||
@ -127,7 +127,7 @@ fun InitializingScreen(modifier: Modifier) {
|
||||
Text(
|
||||
text = stringResource(R.string.setting_up_bluetooth),
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
color = colorScheme.onSurface.copy(alpha = 0.8f)
|
||||
),
|
||||
textAlign = TextAlign.Center
|
||||
@ -136,7 +136,7 @@ fun InitializingScreen(modifier: Modifier) {
|
||||
Text(
|
||||
text = stringResource(R.string.should_take_seconds),
|
||||
style = MaterialTheme.typography.bodySmall.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
color = colorScheme.onSurface.copy(alpha = 0.6f)
|
||||
),
|
||||
textAlign = TextAlign.Center
|
||||
@ -184,7 +184,7 @@ fun InitializationErrorScreen(
|
||||
Text(
|
||||
text = stringResource(R.string.setup_not_complete),
|
||||
style = MaterialTheme.typography.headlineSmall.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = colorScheme.error
|
||||
),
|
||||
@ -201,7 +201,7 @@ fun InitializationErrorScreen(
|
||||
Text(
|
||||
text = errorMessage,
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
color = colorScheme.onSurface
|
||||
),
|
||||
modifier = Modifier.padding(16.dp),
|
||||
@ -220,7 +220,7 @@ fun InitializationErrorScreen(
|
||||
Text(
|
||||
text = stringResource(R.string.try_again),
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontWeight = FontWeight.Bold
|
||||
),
|
||||
modifier = Modifier.padding(vertical = 4.dp)
|
||||
@ -234,7 +234,7 @@ fun InitializationErrorScreen(
|
||||
Text(
|
||||
text = stringResource(R.string.open_settings),
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace
|
||||
fontFamily = BitchatFontFamily
|
||||
),
|
||||
modifier = Modifier.padding(vertical = 4.dp)
|
||||
)
|
||||
|
||||
@ -1,21 +1,21 @@
|
||||
package com.bitchat.android.onboarding
|
||||
|
||||
import androidx.compose.animation.core.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
import androidx.compose.animation.core.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.rotate
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.bitchat.android.ui.theme.BitchatFontFamily
|
||||
import com.bitchat.android.R
|
||||
|
||||
/**
|
||||
@ -80,7 +80,7 @@ private fun LocationDisabledContent(
|
||||
Text(
|
||||
text = stringResource(R.string.location_services_required),
|
||||
style = MaterialTheme.typography.headlineSmall.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = colorScheme.primary
|
||||
),
|
||||
@ -122,7 +122,7 @@ private fun LocationDisabledContent(
|
||||
Text(
|
||||
text = stringResource(R.string.location_explanation),
|
||||
style = MaterialTheme.typography.bodySmall.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
color = colorScheme.onSurface.copy(alpha = 0.8f)
|
||||
)
|
||||
)
|
||||
@ -142,7 +142,7 @@ private fun LocationDisabledContent(
|
||||
Text(
|
||||
text = stringResource(R.string.location_needs_bullets),
|
||||
style = MaterialTheme.typography.bodySmall.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
color = colorScheme.onSurface.copy(alpha = 0.8f)
|
||||
)
|
||||
)
|
||||
@ -166,7 +166,7 @@ private fun LocationDisabledContent(
|
||||
Text(
|
||||
text = stringResource(R.string.open_location_settings),
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontWeight = FontWeight.Bold
|
||||
),
|
||||
modifier = Modifier.padding(vertical = 4.dp)
|
||||
@ -180,7 +180,7 @@ private fun LocationDisabledContent(
|
||||
Text(
|
||||
text = stringResource(R.string.check_again),
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace
|
||||
fontFamily = BitchatFontFamily
|
||||
),
|
||||
modifier = Modifier.padding(vertical = 4.dp)
|
||||
)
|
||||
@ -209,7 +209,7 @@ private fun LocationNotAvailableContent(
|
||||
Text(
|
||||
text = stringResource(R.string.location_services_unavailable),
|
||||
style = MaterialTheme.typography.headlineSmall.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = colorScheme.error
|
||||
),
|
||||
@ -226,7 +226,7 @@ private fun LocationNotAvailableContent(
|
||||
Text(
|
||||
text = stringResource(R.string.location_unavailable_explanation),
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
color = colorScheme.onSurface
|
||||
),
|
||||
modifier = Modifier.padding(16.dp),
|
||||
@ -247,7 +247,7 @@ private fun LocationCheckingContent(
|
||||
Text(
|
||||
text = stringResource(R.string.app_name),
|
||||
style = MaterialTheme.typography.headlineLarge.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = colorScheme.primary
|
||||
),
|
||||
@ -259,7 +259,7 @@ private fun LocationCheckingContent(
|
||||
Text(
|
||||
text = stringResource(R.string.checking_location_services),
|
||||
style = MaterialTheme.typography.bodyLarge.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
color = colorScheme.onSurface.copy(alpha = 0.7f)
|
||||
)
|
||||
)
|
||||
|
||||
@ -1,9 +1,5 @@
|
||||
package com.bitchat.android.onboarding
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Bluetooth
|
||||
import androidx.compose.material.icons.filled.LocationOn
|
||||
@ -13,17 +9,21 @@ import androidx.compose.material.icons.filled.Mic
|
||||
import androidx.compose.material.icons.filled.Security
|
||||
import androidx.compose.material.icons.filled.Wifi
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.bitchat.android.ui.theme.BitchatFontFamily
|
||||
import com.bitchat.android.R
|
||||
|
||||
/**
|
||||
@ -68,7 +68,7 @@ fun PermissionExplanationScreen(
|
||||
Text(
|
||||
text = stringResource(R.string.app_name),
|
||||
style = MaterialTheme.typography.headlineLarge.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 32.sp
|
||||
),
|
||||
@ -79,7 +79,7 @@ fun PermissionExplanationScreen(
|
||||
Text(
|
||||
text = stringResource(R.string.about_tagline),
|
||||
fontSize = 12.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
color = colorScheme.onBackground.copy(alpha = 0.7f)
|
||||
)
|
||||
}
|
||||
@ -117,7 +117,7 @@ fun PermissionExplanationScreen(
|
||||
Text(
|
||||
text = stringResource(R.string.privacy_bullets),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
color = colorScheme.onBackground.copy(alpha = 0.8f)
|
||||
)
|
||||
}
|
||||
@ -164,7 +164,7 @@ fun PermissionExplanationScreen(
|
||||
Text(
|
||||
text = stringResource(R.string.grant_permissions),
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontWeight = FontWeight.Bold
|
||||
),
|
||||
modifier = Modifier.padding(vertical = 4.dp)
|
||||
|
||||
605
app/src/main/java/com/bitchat/android/ui/AboutSections.kt
Normal file
@ -0,0 +1,605 @@
|
||||
package com.bitchat.android.ui
|
||||
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
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.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
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.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.bitchat.android.ui.theme.BitchatFontFamily
|
||||
import com.bitchat.android.R
|
||||
import com.bitchat.android.core.ui.icon.BitChatIcon
|
||||
import com.bitchat.android.ui.theme.BitchatMotion
|
||||
import com.bitchat.android.ui.theme.LocalBitchatPalette
|
||||
|
||||
/**
|
||||
* Building blocks for the redesigned About sheet.
|
||||
*
|
||||
* Kept in a separate file from [AboutSheet] because the sheet itself is mostly wiring for
|
||||
* preferences, whereas these are pure presentation.
|
||||
*/
|
||||
|
||||
/** Horizontal inset shared by every About section, so cards and labels align to one grid. */
|
||||
internal val AboutHorizontalPadding = 20.dp
|
||||
|
||||
/** Card corner radius for grouped rows. */
|
||||
internal val AboutCardShape = RoundedCornerShape(16.dp)
|
||||
|
||||
/** Leading icon column in settings-style sheet rows. */
|
||||
internal val SheetRowLeadingSlot = 22.dp
|
||||
internal val SheetRowLeadingGutter = 16.dp
|
||||
internal val SheetRowHorizontal = 16.dp
|
||||
internal val SheetRowVertical = 13.dp
|
||||
|
||||
/**
|
||||
* Exact height of a people-list row.
|
||||
*
|
||||
* Fixed rather than derived from content: these lists reorder themselves constantly, and a row
|
||||
* whose height depends on its content makes the whole card change height every time the order
|
||||
* changes. Equals the leading glyph plus [SheetRowVertical] above and below.
|
||||
*/
|
||||
internal val SheetRowHeight = SheetRowLeadingSlot + SheetRowVertical * 2
|
||||
internal val SheetRowDividerInset = SheetRowHorizontal + SheetRowLeadingSlot + SheetRowLeadingGutter
|
||||
/** Selection indicator sized for [SheetRowLeadingSlot]. */
|
||||
internal val SheetRowSelectedDot = 12.dp
|
||||
|
||||
/**
|
||||
* Two top-level views of the sheet: what the app is and how to drive it, versus the knobs.
|
||||
*/
|
||||
enum class AboutTab {
|
||||
Info,
|
||||
Settings,
|
||||
}
|
||||
|
||||
/**
|
||||
* Small uppercase section label, e.g. `SETTINGS`.
|
||||
*
|
||||
* Uppercasing happens here rather than in the string resource so translators supply natural
|
||||
* sentence case and locales without a case distinction are unaffected.
|
||||
*/
|
||||
@Composable
|
||||
internal fun AboutSectionLabel(
|
||||
text: String,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val palette = LocalBitchatPalette.current
|
||||
Text(
|
||||
text = text.uppercase(),
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
letterSpacing = 0.8.sp,
|
||||
color = palette.textTertiary,
|
||||
modifier = modifier.padding(start = AboutHorizontalPadding, top = 24.dp, bottom = 8.dp)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Icon + title on one line, optional short subtitle beneath. Used by location / network sheets.
|
||||
*/
|
||||
@Composable
|
||||
internal fun SheetIconSectionHeader(
|
||||
@DrawableRes iconRes: Int,
|
||||
title: String,
|
||||
subtitle: String? = null,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = AboutHorizontalPadding),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(iconRes),
|
||||
contentDescription = null,
|
||||
tint = colorScheme.primary,
|
||||
modifier = Modifier.size(22.dp)
|
||||
)
|
||||
Text(
|
||||
text = title,
|
||||
fontSize = 17.sp,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = colorScheme.primary
|
||||
)
|
||||
}
|
||||
if (!subtitle.isNullOrBlank()) {
|
||||
Text(
|
||||
text = subtitle,
|
||||
fontSize = 12.sp,
|
||||
lineHeight = 17.sp,
|
||||
fontFamily = BitchatFontFamily,
|
||||
color = colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Inset divider used inside grouped sheet cards (aligns with text column after the leading slot). */
|
||||
@Composable
|
||||
internal fun SheetCardDivider() {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(start = SheetRowDividerInset),
|
||||
thickness = 1.dp,
|
||||
color = colorScheme.outlineVariant
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Centered app identity block: logo, wordmark, tagline, version.
|
||||
*/
|
||||
@Composable
|
||||
internal fun AboutHero(
|
||||
versionName: String,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val palette = LocalBitchatPalette.current
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Icon(
|
||||
imageVector = BitChatIcon,
|
||||
contentDescription = null,
|
||||
tint = colorScheme.primary,
|
||||
modifier = Modifier.size(64.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.app_name),
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 40.sp,
|
||||
// Monospace at display size leaves too much air between glyphs; pull it in slightly
|
||||
// so the wordmark reads as a single unit.
|
||||
letterSpacing = (-0.5).sp,
|
||||
color = colorScheme.primary
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.about_tagline),
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 16.sp,
|
||||
color = colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.version_prefix, versionName),
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 12.sp,
|
||||
color = palette.textTertiary
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Two-up tab bar with a sliding underline indicator.
|
||||
*
|
||||
* The indicator animates its offset rather than cross-fading two static bars, which is what
|
||||
* makes the switch feel physically connected to the tap.
|
||||
*/
|
||||
@Composable
|
||||
internal fun AboutTabBar(
|
||||
selected: AboutTab,
|
||||
onSelect: (AboutTab) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val density = LocalDensity.current
|
||||
|
||||
var rowWidth by remember { mutableStateOf(0.dp) }
|
||||
val tabWidth = rowWidth / 2
|
||||
val indicatorOffset by animateDpAsState(
|
||||
targetValue = if (selected == AboutTab.Info) 0.dp else tabWidth,
|
||||
animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing),
|
||||
label = "aboutTabIndicator"
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = AboutHorizontalPadding)
|
||||
.onSizeChanged { size ->
|
||||
rowWidth = with(density) { size.width.toDp() }
|
||||
}
|
||||
) {
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
AboutTabLabel(
|
||||
text = stringResource(R.string.about_tab_info),
|
||||
isSelected = selected == AboutTab.Info,
|
||||
onClick = { onSelect(AboutTab.Info) },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
AboutTabLabel(
|
||||
text = stringResource(R.string.about_tab_settings),
|
||||
isSelected = selected == AboutTab.Settings,
|
||||
onClick = { onSelect(AboutTab.Settings) },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.fillMaxWidth()) {
|
||||
HorizontalDivider(thickness = 1.dp, color = colorScheme.outlineVariant)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
// Lambda overload: the offset is animated every frame, and the non-lambda
|
||||
// version would invalidate composition rather than just layout.
|
||||
.offset { IntOffset(x = indicatorOffset.roundToPx(), y = 0) }
|
||||
.width(tabWidth)
|
||||
.height(2.dp)
|
||||
.background(colorScheme.primary)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AboutTabLabel(
|
||||
text: String,
|
||||
isSelected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
|
||||
val color by animateColorAsState(
|
||||
targetValue = if (isSelected) colorScheme.primary else colorScheme.onSurfaceVariant,
|
||||
animationSpec = tween(BitchatMotion.QUICK_MS, easing = FastOutSlowInEasing),
|
||||
label = "aboutTabLabelColor"
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.height(44.dp)
|
||||
.clickable(onClickLabel = text) { onClick() },
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = text.uppercase(),
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
letterSpacing = 0.8.sp,
|
||||
color = color
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** One line of the "How To Use" list: an icon plus a single instruction. */
|
||||
@Composable
|
||||
private fun AboutInstructionRow(
|
||||
@DrawableRes iconRes: Int,
|
||||
text: String
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = AboutHorizontalPadding, vertical = 10.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.Top
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(iconRes),
|
||||
contentDescription = null,
|
||||
tint = colorScheme.primary,
|
||||
modifier = Modifier
|
||||
.padding(top = 1.dp)
|
||||
.size(22.dp)
|
||||
)
|
||||
Text(
|
||||
text = text,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 20.sp,
|
||||
color = colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The "How To Use" tab: a short, scannable list of the gestures that are not self-evident.
|
||||
*/
|
||||
@Composable
|
||||
internal fun AboutHowToUseSection(modifier: Modifier = Modifier) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
|
||||
Column(modifier = modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = stringResource(R.string.about_how_to_use_heading),
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = colorScheme.primary,
|
||||
modifier = Modifier.padding(
|
||||
start = AboutHorizontalPadding,
|
||||
top = 20.dp,
|
||||
bottom = 8.dp
|
||||
)
|
||||
)
|
||||
|
||||
AboutInstructionRow(
|
||||
iconRes = R.drawable.ic_spec_person,
|
||||
text = stringResource(R.string.about_howto_nickname)
|
||||
)
|
||||
AboutInstructionRow(
|
||||
iconRes = R.drawable.ic_spec_globe,
|
||||
text = stringResource(R.string.about_howto_channels)
|
||||
)
|
||||
AboutInstructionRow(
|
||||
iconRes = R.drawable.ic_spec_people,
|
||||
text = stringResource(R.string.about_howto_people)
|
||||
)
|
||||
AboutInstructionRow(
|
||||
iconRes = R.drawable.ic_spec_bookmark_outline,
|
||||
text = stringResource(R.string.about_howto_bookmark)
|
||||
)
|
||||
AboutInstructionRow(
|
||||
iconRes = R.drawable.ic_spec_mention,
|
||||
text = stringResource(R.string.about_howto_mention)
|
||||
)
|
||||
AboutInstructionRow(
|
||||
iconRes = R.drawable.ic_spec_command,
|
||||
text = stringResource(R.string.about_howto_commands)
|
||||
)
|
||||
AboutInstructionRow(
|
||||
iconRes = R.drawable.ic_spec_waveform,
|
||||
text = stringResource(R.string.about_howto_panic)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Capability list, laid out like [AboutHowToUseSection]: flat rows, no card surface or dividers.
|
||||
*/
|
||||
@Composable
|
||||
internal fun AboutFeatureCard(modifier: Modifier = Modifier) {
|
||||
val features = listOf(
|
||||
Triple(
|
||||
R.drawable.ic_spec_wifi_off,
|
||||
R.string.about_offline_mesh_title,
|
||||
R.string.about_offline_mesh_desc
|
||||
),
|
||||
Triple(
|
||||
R.drawable.ic_spec_lock,
|
||||
R.string.about_e2e_title,
|
||||
R.string.about_e2e_desc
|
||||
),
|
||||
Triple(
|
||||
R.drawable.ic_spec_globe,
|
||||
R.string.about_online_geohash_title,
|
||||
R.string.about_online_geohash_desc
|
||||
),
|
||||
Triple(
|
||||
R.drawable.ic_spec_eye_off,
|
||||
R.string.about_no_tracking_title,
|
||||
R.string.about_no_tracking_desc
|
||||
),
|
||||
)
|
||||
|
||||
Column(modifier = modifier.fillMaxWidth()) {
|
||||
features.forEach { (icon, titleRes, descRes) ->
|
||||
AboutFeatureRow(
|
||||
iconRes = icon,
|
||||
title = stringResource(titleRes),
|
||||
subtitle = stringResource(descRes)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AboutFeatureRow(
|
||||
@DrawableRes iconRes: Int,
|
||||
title: String,
|
||||
subtitle: String
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = AboutHorizontalPadding, vertical = 10.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.Top
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(iconRes),
|
||||
contentDescription = null,
|
||||
tint = colorScheme.primary,
|
||||
modifier = Modifier
|
||||
.padding(top = 1.dp)
|
||||
.size(22.dp)
|
||||
)
|
||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(
|
||||
text = title,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
lineHeight = 20.sp,
|
||||
color = colorScheme.onSurface
|
||||
)
|
||||
Text(
|
||||
text = subtitle,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 12.sp,
|
||||
lineHeight = 17.sp,
|
||||
color = colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Small uppercase pill, e.g. the `RECOMMENDED` badge beside the Tor routing toggle.
|
||||
*/
|
||||
@Composable
|
||||
internal fun BitchatBadge(
|
||||
text: String,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
Box(
|
||||
modifier = modifier
|
||||
.background(colorScheme.primary.copy(alpha = 0.15f), RoundedCornerShape(4.dp))
|
||||
.padding(horizontal = 5.dp, vertical = 2.dp)
|
||||
) {
|
||||
Text(
|
||||
text = text.uppercase(),
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 10.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
letterSpacing = 0.5.sp,
|
||||
color = colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Shared bottom-sheet primitives
|
||||
|
||||
/** Horizontal inset for sheet content. Cards inset from here; labels align to the same edge. */
|
||||
internal val SheetHorizontalPadding = 16.dp
|
||||
|
||||
/**
|
||||
* Section divider label used across the sheets, e.g. `BOOKMARKED`, `NEARBY`, `ON LOCATION`.
|
||||
*/
|
||||
@Composable
|
||||
internal fun SheetSectionLabel(
|
||||
text: String,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val palette = LocalBitchatPalette.current
|
||||
Text(
|
||||
text = text.uppercase(),
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
letterSpacing = 0.8.sp,
|
||||
color = palette.textTertiary,
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(
|
||||
start = SheetHorizontalPadding + 14.dp,
|
||||
end = SheetHorizontalPadding,
|
||||
top = 20.dp,
|
||||
bottom = 6.dp
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Circular tinted badge holding a section's icon, used at the top of the sheets.
|
||||
*/
|
||||
@Composable
|
||||
internal fun SheetHeaderBadge(
|
||||
icon: ImageVector,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
Box(
|
||||
modifier = modifier
|
||||
.size(44.dp)
|
||||
.background(colorScheme.surface, androidx.compose.foundation.shape.CircleShape),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = colorScheme.primary,
|
||||
modifier = Modifier.size(26.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Full-width destructive action, e.g. `REMOVE LOCATION ACCESS`.
|
||||
*
|
||||
* Tinted fill plus an outline rather than a solid red button: the action is legitimate but
|
||||
* rarely wanted, and a solid red block would dominate the sheet.
|
||||
*/
|
||||
@Composable
|
||||
internal fun SheetDestructiveButton(
|
||||
text: String,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
isDestructive: Boolean = true
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val accent = if (isDestructive) colorScheme.error else colorScheme.primary
|
||||
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.height(44.dp),
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
color = accent.copy(alpha = 0.10f),
|
||||
border = BorderStroke(1.dp, accent.copy(alpha = 0.30f))
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
text = text.uppercase(),
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
letterSpacing = 0.8.sp,
|
||||
color = accent
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -3,11 +3,19 @@ package com.bitchat.android.ui
|
||||
import android.content.Intent
|
||||
import android.widget.Toast
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Bluetooth
|
||||
import androidx.compose.material.icons.filled.Security
|
||||
import androidx.compose.material.icons.filled.Speed
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
@ -26,43 +34,17 @@ import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Bluetooth
|
||||
import androidx.compose.material.icons.filled.ChevronRight
|
||||
import androidx.compose.material.icons.filled.CloudDownload
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.Public
|
||||
import androidx.compose.material.icons.filled.Security
|
||||
import androidx.compose.material.icons.filled.Share
|
||||
import androidx.compose.material.icons.filled.Speed
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import androidx.compose.material.icons.filled.Wifi
|
||||
import androidx.compose.material.icons.outlined.Info
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.SliderDefaults
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.SwitchDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
@ -70,14 +52,15 @@ import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.bitchat.android.ui.theme.BitchatFontFamily
|
||||
import com.bitchat.android.R
|
||||
import com.bitchat.android.core.ui.component.button.CloseButton
|
||||
import com.bitchat.android.core.ui.component.sheet.LocalSheetDismiss
|
||||
import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet
|
||||
import com.bitchat.android.hotspot.HotspotActivity
|
||||
import com.bitchat.android.net.ArtiTorManager
|
||||
@ -85,51 +68,10 @@ import com.bitchat.android.net.TorMode
|
||||
import com.bitchat.android.net.TorPreferenceManager
|
||||
import com.bitchat.android.nostr.NostrProofOfWork
|
||||
import com.bitchat.android.nostr.PoWPreferenceManager
|
||||
import com.bitchat.android.ui.theme.BitchatMotion
|
||||
import com.bitchat.android.ui.theme.LocalBitchatPalette
|
||||
import com.bitchat.android.util.UniversalApkManager
|
||||
|
||||
/**
|
||||
* Feature row for displaying app capabilities
|
||||
*/
|
||||
@Composable
|
||||
private fun FeatureRow(
|
||||
icon: ImageVector,
|
||||
title: String,
|
||||
subtitle: String
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.Top
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = colorScheme.primary,
|
||||
modifier = Modifier
|
||||
.padding(top = 2.dp)
|
||||
.size(22.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(14.dp))
|
||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = colorScheme.onSurface
|
||||
)
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = colorScheme.onSurface.copy(alpha = 0.6f),
|
||||
lineHeight = 18.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Theme selection chip with Apple-like styling
|
||||
*/
|
||||
@ -141,17 +83,25 @@ private fun ThemeChip(
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
|
||||
|
||||
|
||||
// Cross-fade the chip so switching theme does not read as two separate flashes (the chip
|
||||
// recolouring plus the whole app recolouring underneath it).
|
||||
val containerColor by animateColorAsState(
|
||||
targetValue = if (selected) colorScheme.primary else colorScheme.surfaceVariant,
|
||||
animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing),
|
||||
label = "themeChipContainer"
|
||||
)
|
||||
val labelColor by animateColorAsState(
|
||||
targetValue = if (selected) Color.White else colorScheme.onSurfaceVariant,
|
||||
animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing),
|
||||
label = "themeChipLabel"
|
||||
)
|
||||
|
||||
Surface(
|
||||
modifier = modifier,
|
||||
onClick = onClick,
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
color = if (selected) {
|
||||
if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D)
|
||||
} else {
|
||||
colorScheme.surfaceVariant.copy(alpha = 0.5f)
|
||||
}
|
||||
color = containerColor
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
@ -161,9 +111,10 @@ private fun ThemeChip(
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 13.sp,
|
||||
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal,
|
||||
color = if (selected) Color.White else colorScheme.onSurface.copy(alpha = 0.8f)
|
||||
color = labelColor
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -184,23 +135,49 @@ private fun SettingsToggleRow(
|
||||
statusIndicator: (@Composable () -> Unit)? = null
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
|
||||
|
||||
val palette = LocalBitchatPalette.current
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
|
||||
// Colours cross-fade so a row becoming available (Tor finishing bootstrap) eases in rather
|
||||
// than popping.
|
||||
val iconTint by animateColorAsState(
|
||||
targetValue = if (enabled) colorScheme.primary else palette.textTertiary,
|
||||
animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing),
|
||||
label = "settingsRowIcon"
|
||||
)
|
||||
val titleColor by animateColorAsState(
|
||||
targetValue = if (enabled) colorScheme.onSurface else palette.textTertiary,
|
||||
animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing),
|
||||
label = "settingsRowTitle"
|
||||
)
|
||||
val subtitleColor by animateColorAsState(
|
||||
targetValue = if (enabled) colorScheme.onSurfaceVariant else palette.textTertiary,
|
||||
animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing),
|
||||
label = "settingsRowSubtitle"
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
// The whole row toggles, not just the switch: a 14.dp-tall switch is a poor target
|
||||
// when there is a full-width row sitting right next to it.
|
||||
.clickable(
|
||||
interactionSource = interactionSource,
|
||||
indication = null,
|
||||
enabled = enabled
|
||||
) { onCheckedChange(!checked) }
|
||||
.padding(horizontal = 16.dp, vertical = 13.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = if (enabled) colorScheme.primary else colorScheme.onSurface.copy(alpha = 0.3f),
|
||||
tint = iconTint,
|
||||
modifier = Modifier.size(22.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(14.dp))
|
||||
|
||||
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||
@ -211,29 +188,32 @@ private fun SettingsToggleRow(
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = if (enabled) colorScheme.onSurface else colorScheme.onSurface.copy(alpha = 0.4f)
|
||||
color = titleColor
|
||||
)
|
||||
statusIndicator?.invoke()
|
||||
}
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = colorScheme.onSurface.copy(alpha = if (enabled) 0.6f else 0.3f),
|
||||
lineHeight = 16.sp
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 12.sp,
|
||||
color = subtitleColor,
|
||||
lineHeight = 17.sp
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
|
||||
|
||||
Switch(
|
||||
checked = checked,
|
||||
onCheckedChange = { if (enabled) onCheckedChange(it) },
|
||||
enabled = enabled,
|
||||
interactionSource = interactionSource,
|
||||
colors = SwitchDefaults.colors(
|
||||
checkedThumbColor = Color.White,
|
||||
checkedTrackColor = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D),
|
||||
checkedTrackColor = colorScheme.primary,
|
||||
uncheckedThumbColor = Color.White,
|
||||
uncheckedTrackColor = colorScheme.surfaceVariant
|
||||
)
|
||||
@ -272,12 +252,14 @@ fun AboutSheet(
|
||||
}
|
||||
val topBarAlpha by animateFloatAsState(
|
||||
targetValue = if (isScrolled) 0.98f else 0f,
|
||||
animationSpec = tween(BitchatMotion.EMPHASIZED_MS, easing = FastOutSlowInEasing),
|
||||
label = "topBarAlpha"
|
||||
)
|
||||
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
|
||||
|
||||
val palette = LocalBitchatPalette.current
|
||||
var selectedTab by remember { mutableStateOf(AboutTab.Info) }
|
||||
|
||||
if (isPresented) {
|
||||
BitchatBottomSheet(
|
||||
modifier = modifier,
|
||||
@ -287,103 +269,49 @@ fun AboutSheet(
|
||||
LazyColumn(
|
||||
state = lazyListState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(top = 80.dp, bottom = 32.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(20.dp)
|
||||
contentPadding = PaddingValues(top = 72.dp, bottom = 32.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(0.dp)
|
||||
) {
|
||||
// Header Section - App Identity
|
||||
item(key = "header") {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 20.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.app_name),
|
||||
style = TextStyle(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 28.sp,
|
||||
letterSpacing = 1.sp
|
||||
),
|
||||
color = colorScheme.onBackground
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.version_prefix, versionName ?: ""),
|
||||
fontSize = 13.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = colorScheme.onBackground.copy(alpha = 0.5f)
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.about_tagline),
|
||||
fontSize = 13.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = colorScheme.onBackground.copy(alpha = 0.6f),
|
||||
modifier = Modifier.padding(top = 4.dp)
|
||||
)
|
||||
}
|
||||
item(key = "hero") {
|
||||
AboutHero(versionName = versionName ?: "")
|
||||
}
|
||||
|
||||
// Features Section - Grouped Card
|
||||
item(key = "features") {
|
||||
Column(modifier = Modifier.padding(horizontal = 20.dp)) {
|
||||
Text(
|
||||
text = stringResource(R.string.about_appearance).uppercase(),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = colorScheme.onBackground.copy(alpha = 0.5f),
|
||||
letterSpacing = 0.5.sp,
|
||||
modifier = Modifier.padding(start = 16.dp, bottom = 8.dp)
|
||||
)
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = colorScheme.surface,
|
||||
shape = RoundedCornerShape(16.dp)
|
||||
) {
|
||||
Column {
|
||||
FeatureRow(
|
||||
icon = Icons.Filled.Bluetooth,
|
||||
title = stringResource(R.string.about_offline_mesh_title),
|
||||
subtitle = stringResource(R.string.about_offline_mesh_desc)
|
||||
)
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(start = 56.dp),
|
||||
color = colorScheme.outline.copy(alpha = 0.12f)
|
||||
)
|
||||
FeatureRow(
|
||||
icon = Icons.Default.Public,
|
||||
title = stringResource(R.string.about_online_geohash_title),
|
||||
subtitle = stringResource(R.string.about_online_geohash_desc)
|
||||
)
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(start = 56.dp),
|
||||
color = colorScheme.outline.copy(alpha = 0.12f)
|
||||
)
|
||||
FeatureRow(
|
||||
icon = Icons.Default.Lock,
|
||||
title = stringResource(R.string.about_e2e_title),
|
||||
subtitle = stringResource(R.string.about_e2e_desc)
|
||||
)
|
||||
}
|
||||
item(key = "tabs") {
|
||||
AboutTabBar(
|
||||
selected = selectedTab,
|
||||
onSelect = { selectedTab = it },
|
||||
modifier = Modifier.padding(top = 24.dp)
|
||||
)
|
||||
}
|
||||
|
||||
if (selectedTab == AboutTab.Info) {
|
||||
// What the app is, then how to drive it. Both are reference material a
|
||||
// new user reads once, so they belong on the same tab.
|
||||
item(key = "features") {
|
||||
Column {
|
||||
AboutSectionLabel(text = stringResource(R.string.about_section_about))
|
||||
AboutFeatureCard()
|
||||
}
|
||||
}
|
||||
|
||||
item(key = "how_to_use") {
|
||||
AboutHowToUseSection()
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedTab == AboutTab.Settings) {
|
||||
// Appearance Section
|
||||
item(key = "appearance") {
|
||||
Column(modifier = Modifier.padding(horizontal = 20.dp)) {
|
||||
Text(
|
||||
text = "THEME",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = colorScheme.onBackground.copy(alpha = 0.5f),
|
||||
letterSpacing = 0.5.sp,
|
||||
modifier = Modifier.padding(start = 16.dp, bottom = 8.dp)
|
||||
)
|
||||
Column {
|
||||
AboutSectionLabel(text = stringResource(R.string.about_section_theme))
|
||||
val themePref by com.bitchat.android.ui.theme.ThemePreferenceManager.themeFlow.collectAsState()
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = AboutHorizontalPadding),
|
||||
color = colorScheme.surface,
|
||||
shape = RoundedCornerShape(16.dp)
|
||||
shape = AboutCardShape
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
@ -425,18 +353,14 @@ fun AboutSheet(
|
||||
val torStatus by torProvider.statusFlow.collectAsState()
|
||||
val torAvailable = remember { torProvider.isTorAvailable() }
|
||||
|
||||
Column(modifier = Modifier.padding(horizontal = 20.dp)) {
|
||||
Text(
|
||||
text = "SETTINGS",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = colorScheme.onBackground.copy(alpha = 0.5f),
|
||||
letterSpacing = 0.5.sp,
|
||||
modifier = Modifier.padding(start = 16.dp, bottom = 8.dp)
|
||||
)
|
||||
Column {
|
||||
AboutSectionLabel(text = stringResource(R.string.about_section_settings))
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = AboutHorizontalPadding),
|
||||
color = colorScheme.surface,
|
||||
shape = RoundedCornerShape(16.dp)
|
||||
shape = AboutCardShape
|
||||
) {
|
||||
Column {
|
||||
// Background Mode Toggle
|
||||
@ -455,12 +379,13 @@ fun AboutSheet(
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(start = 56.dp),
|
||||
color = colorScheme.outline.copy(alpha = 0.12f)
|
||||
modifier = Modifier.padding(start = 54.dp),
|
||||
thickness = 1.dp,
|
||||
color = colorScheme.outlineVariant
|
||||
)
|
||||
|
||||
|
||||
// Proof of Work Toggle
|
||||
SettingsToggleRow(
|
||||
icon = Icons.Filled.Speed,
|
||||
@ -469,16 +394,17 @@ fun AboutSheet(
|
||||
checked = powEnabled,
|
||||
onCheckedChange = { PoWPreferenceManager.setPowEnabled(it) }
|
||||
)
|
||||
|
||||
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(start = 56.dp),
|
||||
color = colorScheme.outline.copy(alpha = 0.12f)
|
||||
modifier = Modifier.padding(start = 54.dp),
|
||||
thickness = 1.dp,
|
||||
color = colorScheme.outlineVariant
|
||||
)
|
||||
|
||||
|
||||
// Tor Toggle
|
||||
SettingsToggleRow(
|
||||
icon = Icons.Filled.Security,
|
||||
title = "Tor Network",
|
||||
title = stringResource(R.string.about_tor_title),
|
||||
subtitle = stringResource(R.string.about_tor_route),
|
||||
checked = torMode.value == TorMode.ON,
|
||||
onCheckedChange = { enabled ->
|
||||
@ -491,9 +417,9 @@ fun AboutSheet(
|
||||
statusIndicator = if (torMode.value == TorMode.ON) {
|
||||
{
|
||||
val statusColor = when {
|
||||
torStatus.running && torStatus.bootstrapPercent >= 100 -> if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D)
|
||||
torStatus.running -> Color(0xFFFF9500)
|
||||
else -> Color(0xFFFF3B30)
|
||||
torStatus.running && torStatus.bootstrapPercent >= 100 -> colorScheme.primary
|
||||
torStatus.running -> palette.accentOrange
|
||||
else -> colorScheme.error
|
||||
}
|
||||
Surface(
|
||||
color = statusColor,
|
||||
@ -857,15 +783,18 @@ fun AboutSheet(
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Tor unavailable hint
|
||||
if (!torAvailable) {
|
||||
Text(
|
||||
text = stringResource(R.string.tor_not_available_in_this_build),
|
||||
fontSize = 12.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = colorScheme.onBackground.copy(alpha = 0.5f),
|
||||
modifier = Modifier.padding(start = 16.dp, top = 8.dp)
|
||||
fontFamily = BitchatFontFamily,
|
||||
color = palette.textTertiary,
|
||||
modifier = Modifier.padding(
|
||||
start = AboutHorizontalPadding + 16.dp,
|
||||
top = 8.dp
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -875,13 +804,15 @@ fun AboutSheet(
|
||||
item(key = "pow_slider") {
|
||||
val powEnabled by PoWPreferenceManager.powEnabled.collectAsState()
|
||||
val powDifficulty by PoWPreferenceManager.powDifficulty.collectAsState()
|
||||
|
||||
|
||||
if (powEnabled) {
|
||||
Column(modifier = Modifier.padding(horizontal = 20.dp)) {
|
||||
Column(modifier = Modifier.padding(top = 12.dp)) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = AboutHorizontalPadding),
|
||||
color = colorScheme.surface,
|
||||
shape = RoundedCornerShape(16.dp)
|
||||
shape = AboutCardShape
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
@ -893,31 +824,39 @@ fun AboutSheet(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "Difficulty",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
text = stringResource(R.string.about_difficulty),
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = colorScheme.onSurface
|
||||
)
|
||||
Text(
|
||||
text = "$powDifficulty bits • ${NostrProofOfWork.estimateMiningTime(powDifficulty)}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = colorScheme.onSurface.copy(alpha = 0.6f)
|
||||
AnimatedCountLabel(
|
||||
count = powDifficulty,
|
||||
text = stringResource(
|
||||
R.string.about_difficulty_value,
|
||||
powDifficulty,
|
||||
NostrProofOfWork.estimateMiningTime(powDifficulty)
|
||||
),
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 12.sp,
|
||||
color = colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Slider(
|
||||
value = powDifficulty.toFloat(),
|
||||
onValueChange = { PoWPreferenceManager.setPowDifficulty(it.toInt()) },
|
||||
valueRange = 0f..32f,
|
||||
steps = 31,
|
||||
colors = SliderDefaults.colors(
|
||||
thumbColor = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D),
|
||||
activeTrackColor = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D)
|
||||
thumbColor = colorScheme.primary,
|
||||
activeTrackColor = colorScheme.primary,
|
||||
inactiveTrackColor = colorScheme.surfaceVariant
|
||||
)
|
||||
)
|
||||
|
||||
Text(
|
||||
|
||||
AnimatedCountLabel(
|
||||
count = powDifficulty,
|
||||
text = when {
|
||||
powDifficulty == 0 -> stringResource(R.string.about_pow_desc_none)
|
||||
powDifficulty <= 8 -> stringResource(R.string.about_pow_desc_very_low)
|
||||
@ -928,8 +867,8 @@ fun AboutSheet(
|
||||
else -> stringResource(R.string.about_pow_desc_extreme)
|
||||
},
|
||||
fontSize = 12.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = colorScheme.onSurface.copy(alpha = 0.5f)
|
||||
fontFamily = BitchatFontFamily,
|
||||
color = palette.textTertiary
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -942,13 +881,15 @@ fun AboutSheet(
|
||||
val torMode = remember { mutableStateOf(TorPreferenceManager.get(context)) }
|
||||
val torProvider = remember { ArtiTorManager.getInstance() }
|
||||
val torStatus by torProvider.statusFlow.collectAsState()
|
||||
|
||||
|
||||
if (torMode.value == TorMode.ON) {
|
||||
Column(modifier = Modifier.padding(horizontal = 20.dp)) {
|
||||
Column(modifier = Modifier.padding(top = 12.dp)) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = AboutHorizontalPadding),
|
||||
color = colorScheme.surface,
|
||||
shape = RoundedCornerShape(16.dp)
|
||||
shape = AboutCardShape
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
@ -959,14 +900,19 @@ fun AboutSheet(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
val statusColor = when {
|
||||
torStatus.running && torStatus.bootstrapPercent >= 100 -> if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D)
|
||||
torStatus.running -> Color(0xFFFF9500)
|
||||
else -> Color(0xFFFF3B30)
|
||||
torStatus.running && torStatus.bootstrapPercent >= 100 -> colorScheme.primary
|
||||
torStatus.running -> palette.accentOrange
|
||||
else -> colorScheme.error
|
||||
}
|
||||
Surface(color = statusColor, shape = CircleShape, modifier = Modifier.size(10.dp)) {}
|
||||
Text(
|
||||
text = if (torStatus.running) "Connected (${torStatus.bootstrapPercent}%)" else "Disconnected",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
text = if (torStatus.running) {
|
||||
stringResource(R.string.about_tor_connected, torStatus.bootstrapPercent)
|
||||
} else {
|
||||
stringResource(R.string.about_tor_disconnected)
|
||||
},
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = colorScheme.onSurface
|
||||
)
|
||||
@ -975,8 +921,8 @@ fun AboutSheet(
|
||||
Text(
|
||||
text = torStatus.lastLogLine.take(120),
|
||||
fontSize = 11.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = colorScheme.onSurface.copy(alpha = 0.5f),
|
||||
fontFamily = BitchatFontFamily,
|
||||
color = palette.textTertiary,
|
||||
maxLines = 2
|
||||
)
|
||||
}
|
||||
@ -986,67 +932,36 @@ fun AboutSheet(
|
||||
}
|
||||
}
|
||||
|
||||
// Emergency Warning
|
||||
item(key = "warning") {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 20.dp)
|
||||
.fillMaxWidth(),
|
||||
color = colorScheme.error.copy(alpha = 0.1f),
|
||||
shape = RoundedCornerShape(16.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.Top
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Warning,
|
||||
contentDescription = null,
|
||||
tint = colorScheme.error,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(
|
||||
text = stringResource(R.string.about_emergency_title),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = colorScheme.error
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.about_emergency_tip),
|
||||
fontSize = 13.sp,
|
||||
color = colorScheme.onSurface.copy(alpha = 0.7f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} // end Settings tab
|
||||
|
||||
// Footer
|
||||
item(key = "footer") {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 20.dp),
|
||||
.padding(
|
||||
start = AboutHorizontalPadding,
|
||||
end = AboutHorizontalPadding,
|
||||
top = 24.dp
|
||||
),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
if (onShowDebug != null) {
|
||||
if (selectedTab == AboutTab.Settings && onShowDebug != null) {
|
||||
TextButton(onClick = onShowDebug) {
|
||||
Text(
|
||||
text = stringResource(R.string.about_debug_settings),
|
||||
fontSize = 13.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
color = colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = stringResource(R.string.about_footer),
|
||||
fontSize = 12.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = colorScheme.onSurface.copy(alpha = 0.4f)
|
||||
fontSize = 11.sp,
|
||||
fontFamily = BitchatFontFamily,
|
||||
color = palette.textTertiary
|
||||
)
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
}
|
||||
@ -1059,10 +974,11 @@ fun AboutSheet(
|
||||
.align(Alignment.TopCenter)
|
||||
.fillMaxWidth()
|
||||
.height(64.dp)
|
||||
.background(MaterialTheme.colorScheme.background.copy(alpha = topBarAlpha))
|
||||
.background(colorScheme.background.copy(alpha = topBarAlpha))
|
||||
) {
|
||||
val dismiss = LocalSheetDismiss.current
|
||||
CloseButton(
|
||||
onClick = onDismiss,
|
||||
onClick = { dismiss?.invoke() ?: onDismiss() },
|
||||
modifier = modifier
|
||||
.align(Alignment.CenterEnd)
|
||||
.padding(horizontal = 16.dp),
|
||||
@ -1112,7 +1028,7 @@ fun PasswordPromptDialog(
|
||||
onValueChange = onPasswordChange,
|
||||
label = { Text(stringResource(R.string.pwd_label), style = MaterialTheme.typography.bodyMedium) },
|
||||
textStyle = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace
|
||||
fontFamily = BitchatFontFamily
|
||||
),
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = colorScheme.primary,
|
||||
|
||||
117
app/src/main/java/com/bitchat/android/ui/AnimatedCount.kt
Normal file
@ -0,0 +1,117 @@
|
||||
package com.bitchat.android.ui
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.SizeTransform
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import com.bitchat.android.ui.theme.BitchatMotion
|
||||
|
||||
/**
|
||||
* Counts that roll to their new value instead of snapping.
|
||||
*
|
||||
* Peer counts change on their own, without the user doing anything, so a hard digit swap is easy
|
||||
* to miss and looks like a rendering fault when it is noticed. Sliding the digits in the
|
||||
* direction the number moved — up when someone joins, down when someone leaves — conveys the
|
||||
* change without needing a separate indicator.
|
||||
*/
|
||||
@Composable
|
||||
fun AnimatedCount(
|
||||
count: Int,
|
||||
modifier: Modifier = Modifier,
|
||||
style: TextStyle = LocalTextStyle.current,
|
||||
color: Color = Color.Unspecified,
|
||||
fontSize: TextUnit = TextUnit.Unspecified,
|
||||
fontWeight: FontWeight? = null,
|
||||
fontFamily: FontFamily? = null,
|
||||
prefix: String = "",
|
||||
) {
|
||||
AnimatedContent(
|
||||
targetState = count,
|
||||
transitionSpec = {
|
||||
val goingUp = targetState > initialState
|
||||
(
|
||||
slideInVertically(
|
||||
animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing),
|
||||
initialOffsetY = { height -> if (goingUp) height else -height }
|
||||
) + fadeIn(tween(BitchatMotion.STANDARD_MS))
|
||||
).togetherWith(
|
||||
slideOutVertically(
|
||||
animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing),
|
||||
targetOffsetY = { height -> if (goingUp) -height else height }
|
||||
) + fadeOut(tween(BitchatMotion.QUICK_MS))
|
||||
// Clip so the outgoing digit cannot bleed past the text bounds mid-transition.
|
||||
) using SizeTransform(clip = true)
|
||||
},
|
||||
modifier = modifier,
|
||||
label = "animatedCount"
|
||||
) { value ->
|
||||
Text(
|
||||
text = "$prefix$value",
|
||||
style = style,
|
||||
color = color,
|
||||
fontSize = fontSize,
|
||||
fontWeight = fontWeight,
|
||||
fontFamily = fontFamily,
|
||||
maxLines = 1
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cross-fades a label whose text embeds a count, e.g. `People (7)` or `3 people`.
|
||||
*
|
||||
* Used where the number is not isolated in its own composable and cannot be rolled on its own.
|
||||
* The transition is keyed on [count] rather than on [text], so a label changing for some other
|
||||
* reason — a locale switch, say — does not animate.
|
||||
*/
|
||||
@Composable
|
||||
fun AnimatedCountLabel(
|
||||
count: Int,
|
||||
text: String,
|
||||
modifier: Modifier = Modifier,
|
||||
style: TextStyle = LocalTextStyle.current,
|
||||
color: Color = Color.Unspecified,
|
||||
fontSize: TextUnit = TextUnit.Unspecified,
|
||||
fontWeight: FontWeight? = null,
|
||||
fontFamily: FontFamily? = null,
|
||||
) {
|
||||
AnimatedContent(
|
||||
targetState = count,
|
||||
transitionSpec = {
|
||||
fadeIn(tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing))
|
||||
.togetherWith(fadeOut(tween(BitchatMotion.QUICK_MS)))
|
||||
.using(SizeTransform(clip = false))
|
||||
},
|
||||
modifier = modifier,
|
||||
label = "animatedCountLabel"
|
||||
) { state ->
|
||||
// Captured per state, so the outgoing copy keeps rendering the label it entered with.
|
||||
// Reading `text` directly would show the *new* label on both sides of the cross-fade,
|
||||
// turning the transition into a flicker between two identical strings.
|
||||
val stateText = remember(state) { text }
|
||||
Text(
|
||||
text = stateText,
|
||||
style = style,
|
||||
color = color,
|
||||
fontSize = fontSize,
|
||||
fontWeight = fontWeight,
|
||||
fontFamily = fontFamily,
|
||||
maxLines = 1
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,85 @@
|
||||
package com.bitchat.android.ui
|
||||
|
||||
import androidx.compose.animation.BoundsTransform
|
||||
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
||||
import androidx.compose.animation.animateBounds
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.FiniteAnimationSpec
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.VisibilityThreshold
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.layout.LookaheadScope
|
||||
|
||||
/**
|
||||
* Motion for a row moving to a new position, or resizing in place.
|
||||
*
|
||||
* People lists reorder themselves constantly and without user input — someone sends a DM and jumps
|
||||
* to the top, a peer drops off the mesh, a favourite comes online. Rows teleporting between
|
||||
* positions makes the list feel unreliable and costs the reader their place in it.
|
||||
*/
|
||||
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||
private val RowBoundsTransform = BoundsTransform { _, _ ->
|
||||
spring(
|
||||
dampingRatio = Spring.DampingRatioNoBouncy,
|
||||
stiffness = Spring.StiffnessMediumLow,
|
||||
visibilityThreshold = Rect.VisibilityThreshold
|
||||
)
|
||||
}
|
||||
|
||||
/** Entry fade for a row that was not previously in the list. */
|
||||
private val RowEnterSpec: FiniteAnimationSpec<Float> =
|
||||
spring(dampingRatio = Spring.DampingRatioNoBouncy, stiffness = 900f)
|
||||
|
||||
/**
|
||||
* A vertical list whose rows animate when they are added, removed, or reordered.
|
||||
*
|
||||
* Deliberately not a `LazyColumn`: both people lists live *inside* an outer `LazyColumn` item, where
|
||||
* nesting another lazy list is not possible. [LookaheadScope] plus [Modifier.animateBounds] gives
|
||||
* the same reorder-and-resize animation that `LazyItemScope.animateItem` provides, without the list
|
||||
* needing to be lazy. These lists are bounded (and the geohash one is explicitly capped), so
|
||||
* nothing is lost by composing every row.
|
||||
*
|
||||
* Rows are keyed so identity survives reordering. Without stable keys a row that moved would look
|
||||
* like a different row appearing, and would fade instead of sliding.
|
||||
*/
|
||||
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||
@Composable
|
||||
fun <T> AnimatedRowColumn(
|
||||
items: List<T>,
|
||||
key: (T) -> Any,
|
||||
modifier: Modifier = Modifier,
|
||||
row: @Composable (index: Int, item: T) -> Unit
|
||||
) {
|
||||
LookaheadScope {
|
||||
Column(modifier = modifier) {
|
||||
items.forEachIndexed { index, item ->
|
||||
key(key(item)) {
|
||||
// Fades the row in on the composition it first appears, then never again —
|
||||
// reordering an existing row must slide, not blink.
|
||||
val enter = remember { Animatable(0f) }
|
||||
LaunchedEffect(Unit) { enter.animateTo(1f, RowEnterSpec) }
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.animateBounds(
|
||||
lookaheadScope = this@LookaheadScope,
|
||||
boundsTransform = RowBoundsTransform
|
||||
)
|
||||
.graphicsLayer { alpha = enter.value }
|
||||
) {
|
||||
row(index, item)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,67 +1,277 @@
|
||||
package com.bitchat.android.ui
|
||||
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.graphics.lerp
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.bitchat.android.R
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.bitchat.android.ui.theme.BitchatFontFamily
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import com.bitchat.android.R
|
||||
import com.bitchat.android.core.ui.component.button.BitChatBrandButton
|
||||
import com.bitchat.android.net.ArtiTorManager
|
||||
import com.bitchat.android.net.TorMode
|
||||
import com.bitchat.android.ui.theme.BitchatMotion
|
||||
import com.bitchat.android.ui.theme.LocalBitchatPalette
|
||||
|
||||
/**
|
||||
* Header components for ChatScreen
|
||||
* Extracted from ChatScreen.kt for better organization
|
||||
*/
|
||||
|
||||
/** Height of the chat top bar. Taller than the old 42.dp so 44.dp tap targets fit properly. */
|
||||
val ChatHeaderHeight = 52.dp
|
||||
|
||||
/**
|
||||
* The single visible glyph size used by every icon in the top bar.
|
||||
*
|
||||
* The Figma header pairs 16 px icons with compact labels. At our 17sp header scale, 19dp preserves
|
||||
* that icon-to-cap-height relationship without affecting the surrounding 44dp touch targets.
|
||||
*/
|
||||
internal val HeaderIconSize = 19.dp
|
||||
|
||||
/**
|
||||
* Text size for the top bar's labels: nickname, channel name, peer count.
|
||||
*
|
||||
* A step up from the 15.sp body scale. The bar is the app's primary status readout and was
|
||||
* noticeably harder to read than the messages below it; the extra point costs nothing because
|
||||
* the bar's height is driven by [HeaderTapTarget], not by the text.
|
||||
*/
|
||||
private val HeaderTextSize = 17.sp
|
||||
|
||||
/** Minimum tap target for every interactive element in the header. */
|
||||
private val HeaderTapTarget = 44.dp
|
||||
|
||||
/** Corner radius for the header's tappable label+icon clusters. */
|
||||
private val HeaderClusterShape = RoundedCornerShape(8.dp)
|
||||
|
||||
/**
|
||||
* Edge insets for the bar.
|
||||
*
|
||||
* Asymmetric because the leading glyph sits in a 44.dp tap target whose padding already supplies
|
||||
* some optical inset, while the trailing action's does the same on the other side.
|
||||
*/
|
||||
internal val HeaderInsetStart = 12.dp
|
||||
internal val HeaderInsetEnd = 8.dp
|
||||
|
||||
/**
|
||||
* A minimum-48x40 tap target wrapping a small icon.
|
||||
*
|
||||
* The old header used bare 16.dp icons with `Modifier.clickable`, which produced tap targets far
|
||||
* below the accessibility minimum and made the channel/bookmark controls genuinely hard to hit.
|
||||
*/
|
||||
@Composable
|
||||
private fun HeaderIconButton(
|
||||
onClick: () -> Unit,
|
||||
contentDescription: String?,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.size(HeaderTapTarget)
|
||||
.clip(CircleShape)
|
||||
.pressScaleClickable(onClick = onClick, onClickLabel = contentDescription),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tor health for location-channel header glyphs.
|
||||
*
|
||||
* Status colours are heavily muted (blended into [normal]) so they read as a soft signal
|
||||
* rather than an alarm. Connecting / not-yet-running also drives a slow glow pulse.
|
||||
*/
|
||||
internal data class TorConnectionVisual(
|
||||
val tint: Color,
|
||||
/** True while Tor is enabled but not fully bootstrapped — drives a pulse. */
|
||||
val isProgress: Boolean,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun TorStatusDot(
|
||||
modifier: Modifier = Modifier
|
||||
internal fun rememberTorConnectionVisual(normal: Color): TorConnectionVisual {
|
||||
val palette = LocalBitchatPalette.current
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val torStatus by remember { ArtiTorManager.getInstance() }.statusFlow.collectAsState()
|
||||
|
||||
// ~28% of the loud accent mixed into the base tint keeps the hue without intensity.
|
||||
val mutedConnecting = lerp(normal, palette.accentOrange, 0.28f)
|
||||
val mutedFailed = lerp(normal, colorScheme.error, 0.30f)
|
||||
|
||||
val target = when {
|
||||
torStatus.mode == TorMode.OFF -> TorConnectionVisual(normal, isProgress = false)
|
||||
torStatus.running && torStatus.bootstrapPercent >= 100 ->
|
||||
TorConnectionVisual(normal, isProgress = false)
|
||||
torStatus.running -> TorConnectionVisual(mutedConnecting, isProgress = true)
|
||||
else -> TorConnectionVisual(mutedFailed, isProgress = true)
|
||||
}
|
||||
|
||||
val animatedTint by animateColorAsState(
|
||||
targetValue = target.tint,
|
||||
animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing),
|
||||
label = "torConnectionTint"
|
||||
)
|
||||
return TorConnectionVisual(tint = animatedTint, isProgress = target.isProgress)
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft, slow brightness pulse used while Tor is connecting. Keeps scale fixed so layout
|
||||
* does not shift; only opacity / a faint halo breathe.
|
||||
*/
|
||||
@Composable
|
||||
internal fun TorAwareHeaderIcon(
|
||||
imageVector: ImageVector,
|
||||
tint: Color,
|
||||
isProgress: Boolean,
|
||||
contentDescription: String?,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val torProvider = remember { com.bitchat.android.net.ArtiTorManager.getInstance() }
|
||||
val torStatus by torProvider.statusFlow.collectAsState()
|
||||
|
||||
if (torStatus.mode != com.bitchat.android.net.TorMode.OFF) {
|
||||
val dotColor = when {
|
||||
torStatus.running && torStatus.bootstrapPercent < 100 -> Color(0xFFFF9500) // Orange - bootstrapping
|
||||
torStatus.running && torStatus.bootstrapPercent >= 100 -> Color(0xFF00C851) // Green - connected
|
||||
else -> Color.Red // Red - error/disconnected
|
||||
}
|
||||
Canvas(
|
||||
modifier = modifier
|
||||
) {
|
||||
val radius = size.minDimension / 2
|
||||
drawCircle(
|
||||
color = dotColor,
|
||||
radius = radius,
|
||||
center = Offset(size.width / 2, size.height / 2)
|
||||
val pulse = if (isProgress) {
|
||||
val transition = rememberInfiniteTransition(label = "torGlow")
|
||||
transition.animateFloat(
|
||||
initialValue = 0.42f,
|
||||
targetValue = 1f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(durationMillis = 1800, easing = FastOutSlowInEasing),
|
||||
repeatMode = RepeatMode.Reverse
|
||||
),
|
||||
label = "torGlowPulse"
|
||||
).value
|
||||
} else {
|
||||
1f
|
||||
}
|
||||
|
||||
// Fixed layout footprint = icon size. Glow is drawn larger via requiredSize so it never
|
||||
// pushes neighbouring text when the pulse starts/stops.
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = modifier.size(HeaderIconSize)
|
||||
) {
|
||||
if (isProgress) {
|
||||
val glowBrush = remember(tint) {
|
||||
Brush.radialGradient(
|
||||
colorStops = arrayOf(
|
||||
0.0f to tint.copy(alpha = 0.55f),
|
||||
0.45f to tint.copy(alpha = 0.22f),
|
||||
1.0f to Color.Transparent,
|
||||
)
|
||||
)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.requiredSize(HeaderIconSize + 14.dp)
|
||||
.graphicsLayer { alpha = pulse * 0.85f }
|
||||
.background(glowBrush)
|
||||
)
|
||||
}
|
||||
Icon(
|
||||
imageVector = imageVector,
|
||||
contentDescription = contentDescription,
|
||||
modifier = Modifier
|
||||
.size(HeaderIconSize)
|
||||
.graphicsLayer {
|
||||
alpha = if (isProgress) 0.55f + pulse * 0.45f else 1f
|
||||
},
|
||||
tint = tint
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Painter-resource counterpart used by the extracted Figma SVG family. */
|
||||
@Composable
|
||||
internal fun TorAwareHeaderIcon(
|
||||
painter: Painter,
|
||||
tint: Color,
|
||||
isProgress: Boolean,
|
||||
contentDescription: String?,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val pulse = if (isProgress) {
|
||||
val transition = rememberInfiniteTransition(label = "torPainterGlow")
|
||||
transition.animateFloat(
|
||||
initialValue = 0.42f,
|
||||
targetValue = 1f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(durationMillis = 1800, easing = FastOutSlowInEasing),
|
||||
repeatMode = RepeatMode.Reverse
|
||||
),
|
||||
label = "torPainterGlowPulse"
|
||||
).value
|
||||
} else {
|
||||
1f
|
||||
}
|
||||
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = modifier.size(HeaderIconSize)
|
||||
) {
|
||||
if (isProgress) {
|
||||
val glowBrush = remember(tint) {
|
||||
Brush.radialGradient(
|
||||
colorStops = arrayOf(
|
||||
0.0f to tint.copy(alpha = 0.55f),
|
||||
0.45f to tint.copy(alpha = 0.22f),
|
||||
1.0f to Color.Transparent,
|
||||
)
|
||||
)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.requiredSize(HeaderIconSize + 14.dp)
|
||||
.graphicsLayer { alpha = pulse * 0.85f }
|
||||
.background(glowBrush)
|
||||
)
|
||||
}
|
||||
Icon(
|
||||
painter = painter,
|
||||
contentDescription = contentDescription,
|
||||
modifier = Modifier
|
||||
.size(HeaderIconSize)
|
||||
.graphicsLayer {
|
||||
alpha = if (isProgress) 0.55f + pulse * 0.45f else 1f
|
||||
},
|
||||
tint = tint
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -70,39 +280,156 @@ fun NoiseSessionIcon(
|
||||
sessionState: String?,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val (icon, color, contentDescription) = when (sessionState) {
|
||||
val palette = LocalBitchatPalette.current
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
// The pre-redesign colours for the first two states were `0x87878700`, i.e. alpha 0x87 with
|
||||
// an all-but-transparent RGB - the icons were effectively invisible. They now use the
|
||||
// palette's secondary text colour.
|
||||
val (iconRes, color, contentDescription) = when (sessionState) {
|
||||
"uninitialized" -> Triple(
|
||||
Icons.Outlined.NoEncryption,
|
||||
Color(0x87878700), // Grey - ready to establish
|
||||
R.drawable.ic_spec_lock_open,
|
||||
colorScheme.onSurfaceVariant,
|
||||
stringResource(R.string.cd_ready_for_handshake)
|
||||
)
|
||||
"handshaking" -> Triple(
|
||||
Icons.Outlined.Sync,
|
||||
Color(0x87878700), // Grey - in progress
|
||||
R.drawable.ic_spec_sync,
|
||||
colorScheme.onSurfaceVariant,
|
||||
stringResource(R.string.cd_handshake_in_progress)
|
||||
)
|
||||
"established" -> Triple(
|
||||
Icons.Filled.Lock,
|
||||
Color(0xFFFF9500), // Orange - secure
|
||||
R.drawable.ic_spec_lock,
|
||||
colorScheme.primary,
|
||||
stringResource(R.string.cd_encrypted)
|
||||
)
|
||||
else -> { // "failed" or any other state
|
||||
Triple(
|
||||
Icons.Outlined.Warning,
|
||||
Color(0xFFFF4444), // Red - error
|
||||
R.drawable.ic_spec_warning,
|
||||
colorScheme.error,
|
||||
stringResource(R.string.cd_handshake_failed)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
painter = painterResource(iconRes),
|
||||
contentDescription = contentDescription,
|
||||
modifier = modifier,
|
||||
tint = color
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reachability glyph for a conversation, drawn from the same spec set the main header uses.
|
||||
*
|
||||
* Mirrors the main header's channel button: a globe for anything reached over the internet, the
|
||||
* range mark for the local mesh, and the more specific transport glyph when we know it.
|
||||
*/
|
||||
@DrawableRes
|
||||
internal fun conversationTransportIcon(
|
||||
isReachedOverInternet: Boolean,
|
||||
isWifiAware: Boolean,
|
||||
isDirect: Boolean
|
||||
): Int = when {
|
||||
isReachedOverInternet -> R.drawable.ic_spec_globe
|
||||
isWifiAware -> R.drawable.ic_spec_wifi
|
||||
isDirect -> R.drawable.ic_spec_bluetooth
|
||||
else -> R.drawable.ic_spec_routed
|
||||
}
|
||||
|
||||
/**
|
||||
* The shared chrome for a conversation header — private chats and channels alike.
|
||||
*
|
||||
* Deliberately built from the same tokens as [MainHeader] rather than from `TopAppBar`: identical
|
||||
* height, identical 12/8.dp edge insets, the leading glyph in a [HeaderTapTarget]-sized slot so it
|
||||
* lands exactly where the brand mark does, the same -6.dp optical nudge pulling the title toward
|
||||
* that glyph, and the same [HeaderTextSize]. Anything less and the header visibly shifts as you
|
||||
* move between the main timeline and a conversation.
|
||||
*
|
||||
* Actions are right-aligned and unweighted, so a long title yields space to them rather than
|
||||
* pushing them off screen.
|
||||
*/
|
||||
@Composable
|
||||
fun ConversationHeader(
|
||||
@DrawableRes leadingIconRes: Int,
|
||||
leadingIconTint: Color,
|
||||
title: String,
|
||||
modifier: Modifier = Modifier,
|
||||
onTitleClick: (() -> Unit)? = null,
|
||||
leadingContentDescription: String? = null,
|
||||
actions: @Composable RowScope.() -> Unit = {}
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.height(ChatHeaderHeight)
|
||||
.padding(start = HeaderInsetStart, end = HeaderInsetEnd),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.size(HeaderTapTarget),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(leadingIconRes),
|
||||
contentDescription = leadingContentDescription,
|
||||
modifier = Modifier.size(HeaderIconSize),
|
||||
tint = leadingIconTint
|
||||
)
|
||||
}
|
||||
|
||||
// Same optical correction as the main header: the 44.dp tap target leaves more gap
|
||||
// than the design wants between glyph and label.
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontSize = HeaderTextSize,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = colorScheme.primary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier
|
||||
.offset(x = (-6).dp)
|
||||
.then(
|
||||
if (onTitleClick != null) {
|
||||
Modifier
|
||||
.clip(HeaderClusterShape)
|
||||
.pressScaleClickable(onClick = onTitleClick)
|
||||
.padding(horizontal = 6.dp, vertical = 4.dp)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||
content = actions
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** An action slot in a [ConversationHeader], matching the main header's icon buttons. */
|
||||
@Composable
|
||||
fun ConversationHeaderAction(
|
||||
onClick: () -> Unit,
|
||||
contentDescription: String?,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable () -> Unit
|
||||
) = HeaderIconButton(
|
||||
onClick = onClick,
|
||||
contentDescription = contentDescription,
|
||||
modifier = modifier,
|
||||
content = content
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun NicknameEditor(
|
||||
value: String,
|
||||
@ -125,15 +452,17 @@ fun NicknameEditor(
|
||||
Text(
|
||||
text = stringResource(R.string.at_symbol),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = colorScheme.primary.copy(alpha = 0.8f)
|
||||
fontSize = HeaderTextSize,
|
||||
color = colorScheme.primary.copy(alpha = 0.7f)
|
||||
)
|
||||
|
||||
|
||||
BasicTextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
textStyle = MaterialTheme.typography.bodyMedium.copy(
|
||||
color = colorScheme.primary,
|
||||
fontFamily = FontFamily.Monospace
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = HeaderTextSize
|
||||
),
|
||||
cursorBrush = SolidColor(colorScheme.primary),
|
||||
singleLine = true,
|
||||
@ -144,7 +473,7 @@ fun NicknameEditor(
|
||||
}
|
||||
),
|
||||
modifier = Modifier
|
||||
.widthIn(max = 120.dp)
|
||||
.widthIn(max = 150.dp)
|
||||
.horizontalScroll(scrollState)
|
||||
)
|
||||
}
|
||||
@ -161,54 +490,68 @@ fun PeerCounter(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val palette = LocalBitchatPalette.current
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
|
||||
|
||||
// Compute channel-aware people count and color (matches iOS logic exactly)
|
||||
val (peopleCount, countColor) = when (selectedLocationChannel) {
|
||||
is com.bitchat.android.geohash.ChannelID.Location -> {
|
||||
// Geohash channel: show geohash participants
|
||||
val count = geohashPeople.size
|
||||
val green = Color(0xFF00C851) // Standard green
|
||||
Pair(count, if (count > 0) green else Color.Gray)
|
||||
Pair(count, if (count > 0) colorScheme.primary else palette.textTertiary)
|
||||
}
|
||||
is com.bitchat.android.geohash.ChannelID.Mesh,
|
||||
null -> {
|
||||
// Mesh channel: show Bluetooth-connected peers (excluding self)
|
||||
val count = connectedPeers.size
|
||||
val meshBlue = Color(0xFF007AFF) // iOS-style blue for mesh
|
||||
Pair(count, if (isConnected && count > 0) meshBlue else Color.Gray)
|
||||
Pair(count, if (isConnected && count > 0) colorScheme.secondary else palette.textTertiary)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Peers come and go constantly; fading the tint avoids a flicker every time the count
|
||||
// crosses zero.
|
||||
val animatedCountColor by animateColorAsState(
|
||||
targetValue = countColor,
|
||||
animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing),
|
||||
label = "peerCountColor"
|
||||
)
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = modifier.clickable { onClick() }.padding(end = 8.dp) // Added right margin to match "bitchat" logo spacing
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
modifier = modifier
|
||||
.clip(HeaderClusterShape)
|
||||
.pressScaleClickable(onClick = onClick)
|
||||
.height(HeaderTapTarget)
|
||||
.padding(horizontal = 6.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Group,
|
||||
// The extracted people glyph stays legible at the compact header scale; the number
|
||||
// beside it carries the precise count.
|
||||
painter = painterResource(R.drawable.ic_spec_people),
|
||||
contentDescription = when (selectedLocationChannel) {
|
||||
is com.bitchat.android.geohash.ChannelID.Location -> stringResource(R.string.cd_geohash_participants)
|
||||
else -> stringResource(R.string.cd_connected_peers)
|
||||
},
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = countColor
|
||||
modifier = Modifier.size(HeaderIconSize),
|
||||
tint = animatedCountColor
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
|
||||
Text(
|
||||
text = "$peopleCount",
|
||||
AnimatedCount(
|
||||
count = peopleCount,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = countColor,
|
||||
fontSize = 16.sp,
|
||||
fontSize = HeaderTextSize,
|
||||
color = animatedCountColor,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
|
||||
|
||||
if (joinedChannels.isNotEmpty()) {
|
||||
Text(
|
||||
text = stringResource(R.string.channel_count_prefix) + "${joinedChannels.size}",
|
||||
AnimatedCount(
|
||||
count = joinedChannels.size,
|
||||
prefix = stringResource(R.string.channel_count_prefix),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (isConnected) Color(0xFF00C851) else Color.Red,
|
||||
fontSize = 16.sp,
|
||||
fontSize = HeaderTextSize,
|
||||
color = if (isConnected) colorScheme.primary else colorScheme.error,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
@ -266,57 +609,26 @@ private fun ChannelHeader(
|
||||
onSidebarClick: () -> Unit
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
|
||||
Box(modifier = Modifier.fillMaxWidth()) {
|
||||
// Back button - positioned all the way to the left with minimal margin
|
||||
Button(
|
||||
|
||||
// No back affordance: the close action on the right is the way out, exactly as in a private
|
||||
// chat. Leaving the channel outright lives on its row in the network sheet, so it does not
|
||||
// need a second, easily-mistaken home next to the exit.
|
||||
ConversationHeader(
|
||||
leadingIconRes = R.drawable.ic_spec_chat_bubbles,
|
||||
leadingIconTint = colorScheme.primary,
|
||||
leadingContentDescription = null,
|
||||
title = "#$channel",
|
||||
onTitleClick = onSidebarClick
|
||||
) {
|
||||
ConversationHeaderAction(
|
||||
onClick = onBackClick,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = Color.Transparent,
|
||||
contentColor = colorScheme.primary
|
||||
),
|
||||
contentPadding = PaddingValues(horizontal = 4.dp, vertical = 4.dp), // Reduced horizontal padding
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterStart)
|
||||
.offset(x = (-8).dp) // Move even further left to minimize margin
|
||||
contentDescription = stringResource(R.string.close_plain)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.back),
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = colorScheme.primary
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.chat_back),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Title - perfectly centered regardless of other elements
|
||||
Text(
|
||||
text = stringResource(R.string.chat_channel_prefix, channel),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = Color(0xFFFF9500), // Orange to match input field
|
||||
modifier = Modifier
|
||||
.align(Alignment.Center)
|
||||
.clickable { onSidebarClick() }
|
||||
)
|
||||
|
||||
// Leave button - positioned on the right
|
||||
TextButton(
|
||||
onClick = onLeaveChannel,
|
||||
modifier = Modifier.align(Alignment.CenterEnd)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.chat_leave),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = Color.Red
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_spec_close),
|
||||
contentDescription = stringResource(R.string.close_plain),
|
||||
modifier = Modifier.size(HeaderIconSize),
|
||||
tint = colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -334,6 +646,7 @@ private fun MainHeader(
|
||||
viewModel: ChatViewModel
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val palette = LocalBitchatPalette.current
|
||||
val connectedPeers by viewModel.connectedPeers.collectAsStateWithLifecycle()
|
||||
val joinedChannels by viewModel.joinedChannels.collectAsStateWithLifecycle()
|
||||
val hasUnreadChannels by viewModel.unreadChannelMessages.collectAsStateWithLifecycle()
|
||||
@ -342,109 +655,98 @@ private fun MainHeader(
|
||||
val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
|
||||
val geohashPeople by viewModel.geohashPeople.collectAsStateWithLifecycle()
|
||||
|
||||
// Bookmarks store for current geohash toggle (iOS parity)
|
||||
val context = androidx.compose.ui.platform.LocalContext.current
|
||||
val bookmarksStore = remember { com.bitchat.android.geohash.GeohashBookmarksStore.getInstance(context) }
|
||||
val bookmarks by bookmarksStore.bookmarks.collectAsStateWithLifecycle()
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(ChatHeaderHeight)
|
||||
.padding(start = HeaderInsetStart, end = HeaderInsetEnd),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// MARK: - Identity cluster.
|
||||
//
|
||||
// Weighted so it yields space to the status cluster rather than pushing it off screen.
|
||||
// Compose measures unweighted children first, so the icons on the right always get the
|
||||
// width they need and a long nickname simply scrolls within what is left.
|
||||
Row(
|
||||
modifier = Modifier.fillMaxHeight(),
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
BitChatBrandButton(
|
||||
onClick = onTitleClick,
|
||||
onTripleClick = onTripleTitleClick,
|
||||
contentDescription = stringResource(R.string.cd_open_about),
|
||||
modifier = Modifier.size(HeaderTapTarget),
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "/",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = colorScheme.primary,
|
||||
)
|
||||
// Nudge toward the brand glyph: the 44.dp tap target leaves more optical gap than
|
||||
// spacing between the mark and the path label.
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.offset(x = (-6).dp)
|
||||
) {
|
||||
Text(
|
||||
text = "/",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontSize = HeaderTextSize,
|
||||
// Dimmed: the slash is a separator, not content. At full brightness it competed
|
||||
// with the nickname beside it.
|
||||
color = colorScheme.primary.copy(alpha = 0.45f),
|
||||
modifier = Modifier.padding(end = 2.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(2.dp))
|
||||
|
||||
NicknameEditor(
|
||||
value = nickname,
|
||||
onValueChange = onNicknameChange
|
||||
)
|
||||
}
|
||||
|
||||
// Right section with location channels button and peer counter
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(5.dp)
|
||||
) {
|
||||
|
||||
// Unread private messages badge (click to open most recent DM)
|
||||
if (hasUnreadPrivateMessages.isNotEmpty()) {
|
||||
// Render icon directly to avoid symbol resolution issues
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Email,
|
||||
contentDescription = stringResource(R.string.cd_unread_private_messages),
|
||||
modifier = Modifier
|
||||
.size(16.dp)
|
||||
.clickable { viewModel.openLatestUnreadPrivateChat() },
|
||||
tint = Color(0xFFFF9500)
|
||||
NicknameEditor(
|
||||
value = nickname,
|
||||
onValueChange = onNicknameChange
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Status cluster.
|
||||
//
|
||||
// Order, left to right: unread DMs, notes, channel, people.
|
||||
// Tor health is read from the location channel / notes icon colour rather than a
|
||||
// dedicated status dot.
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
// Tight, because every child below is its own >=44.dp tap target.
|
||||
horizontalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
// Unread private messages badge (click to open most recent DM)
|
||||
if (hasUnreadPrivateMessages.isNotEmpty()) {
|
||||
HeaderIconButton(
|
||||
onClick = { viewModel.openLatestUnreadPrivateChat() },
|
||||
contentDescription = stringResource(R.string.cd_unread_private_messages)
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_spec_envelope),
|
||||
contentDescription = stringResource(R.string.cd_unread_private_messages),
|
||||
modifier = Modifier.size(HeaderIconSize),
|
||||
tint = palette.accentOrange
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Location notes + channel badge: one tight unit so the document glyph and the
|
||||
// bluetooth/globe glyph sit at the same visual pitch as other header pairings.
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(0.dp)
|
||||
) {
|
||||
LocationNotesButton(
|
||||
viewModel = viewModel,
|
||||
onClick = onLocationNotesClick
|
||||
)
|
||||
|
||||
// Bookmarking lives in the Location Channels sheet, one tap away via the channel
|
||||
// button. Duplicating it here bought a shortcut for a rare action at the cost
|
||||
// of a slot in the app's most crowded row.
|
||||
|
||||
// Location channels button (matching iOS implementation) and bookmark grouped tightly
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(end = 4.dp)) {
|
||||
LocationChannelsButton(
|
||||
viewModel = viewModel,
|
||||
onClick = onLocationChannelsClick
|
||||
)
|
||||
|
||||
// Bookmark toggle for current geohash (not shown for mesh)
|
||||
val currentGeohash: String? = when (val sc = selectedLocationChannel) {
|
||||
is com.bitchat.android.geohash.ChannelID.Location -> sc.channel.geohash
|
||||
else -> null
|
||||
}
|
||||
if (currentGeohash != null) {
|
||||
val isBookmarked = bookmarks.contains(currentGeohash)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(start = 2.dp) // minimal gap between geohash and bookmark
|
||||
.size(20.dp)
|
||||
.clickable { bookmarksStore.toggle(currentGeohash) },
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (isBookmarked) Icons.Filled.Bookmark else Icons.Outlined.BookmarkBorder,
|
||||
contentDescription = stringResource(R.string.cd_toggle_bookmark),
|
||||
tint = if (isBookmarked) Color(0xFF00C851) else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.75f),
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Location Notes button (extracted to separate component)
|
||||
LocationNotesButton(
|
||||
viewModel = viewModel,
|
||||
onClick = onLocationNotesClick
|
||||
)
|
||||
|
||||
// Tor status dot when Tor is enabled
|
||||
TorStatusDot(
|
||||
modifier = Modifier
|
||||
.size(8.dp)
|
||||
.padding(start = 0.dp, end = 2.dp)
|
||||
)
|
||||
|
||||
// PoW status indicator
|
||||
PoWStatusIndicator(
|
||||
modifier = Modifier,
|
||||
style = PoWIndicatorStyle.COMPACT
|
||||
)
|
||||
Spacer(modifier = Modifier.width(2.dp))
|
||||
PeerCounter(
|
||||
connectedPeers = connectedPeers.filter { it != viewModel.myPeerID },
|
||||
joinedChannels = joinedChannels,
|
||||
@ -458,56 +760,72 @@ private fun MainHeader(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Current channel indicator: a globe for geohash channels, a mesh glyph for the local mesh.
|
||||
*
|
||||
* The design brief asked for the "addition of globe icon to represent channels". Previously this
|
||||
* was a text-only badge wrapped in an M3 [Button], which imposed a hidden 58.dp minimum width
|
||||
* and 40.dp minimum height that fought the header's explicit sizing.
|
||||
*/
|
||||
@Composable
|
||||
private fun LocationChannelsButton(
|
||||
viewModel: ChatViewModel,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
|
||||
|
||||
// Get current channel selection from location manager
|
||||
val selectedChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
|
||||
val teleported by viewModel.isTeleported.collectAsStateWithLifecycle()
|
||||
|
||||
val (badgeText, badgeColor) = when (selectedChannel) {
|
||||
is com.bitchat.android.geohash.ChannelID.Mesh -> {
|
||||
"#mesh" to Color(0xFF007AFF) // iOS blue for mesh
|
||||
}
|
||||
is com.bitchat.android.geohash.ChannelID.Location -> {
|
||||
val geohash = (selectedChannel as com.bitchat.android.geohash.ChannelID.Location).channel.geohash
|
||||
"#$geohash" to Color(0xFF00C851) // Green for location
|
||||
}
|
||||
null -> "#mesh" to Color(0xFF007AFF) // Default to mesh
|
||||
|
||||
val isLocation = selectedChannel is com.bitchat.android.geohash.ChannelID.Location
|
||||
val badgeText = when (val channel = selectedChannel) {
|
||||
// Geohashes keep the '#' because that is how they are written and typed everywhere else.
|
||||
is com.bitchat.android.geohash.ChannelID.Location -> "#${channel.channel.geohash}"
|
||||
// The local mesh is not a hashtag channel, and the mesh glyph already says what it is,
|
||||
// so it is plain "mesh".
|
||||
else -> stringResource(R.string.mesh_label)
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = onClick,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = Color.Transparent,
|
||||
contentColor = badgeColor
|
||||
),
|
||||
contentPadding = PaddingValues(start = 4.dp, end = 0.dp, top = 2.dp, bottom = 2.dp)
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = badgeText,
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace
|
||||
),
|
||||
color = badgeColor,
|
||||
maxLines = 1
|
||||
val channelColor = if (isLocation) colorScheme.primary else colorScheme.secondary
|
||||
// Tor status only tints the globe (location channels). The local mesh stays blue.
|
||||
val torVisual = if (isLocation) {
|
||||
rememberTorConnectionVisual(normal = channelColor)
|
||||
} else {
|
||||
TorConnectionVisual(tint = channelColor, isProgress = false)
|
||||
}
|
||||
val badgeIconRes = if (isLocation) {
|
||||
R.drawable.ic_spec_globe
|
||||
} else {
|
||||
R.drawable.ic_spec_range
|
||||
}
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
modifier = Modifier
|
||||
.clip(HeaderClusterShape)
|
||||
.pressScaleClickable(
|
||||
onClick = onClick,
|
||||
onClickLabel = stringResource(R.string.location_channels_title)
|
||||
)
|
||||
|
||||
// Teleportation indicator (like iOS)
|
||||
if (teleported) {
|
||||
Spacer(modifier = Modifier.width(2.dp))
|
||||
Icon(
|
||||
imageVector = Icons.Default.PinDrop,
|
||||
contentDescription = stringResource(R.string.cd_teleported),
|
||||
modifier = Modifier.size(12.dp),
|
||||
tint = badgeColor
|
||||
)
|
||||
}
|
||||
}
|
||||
.height(HeaderTapTarget)
|
||||
// No start padding: the notes icon is paired directly to the left; keep end
|
||||
// padding so the gap to PeerCounter matches other cluster separations.
|
||||
.padding(start = 0.dp, end = 6.dp)
|
||||
) {
|
||||
TorAwareHeaderIcon(
|
||||
painter = painterResource(badgeIconRes),
|
||||
tint = torVisual.tint,
|
||||
isProgress = torVisual.isProgress,
|
||||
contentDescription = stringResource(R.string.cd_tor_status)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = badgeText,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontSize = HeaderTextSize,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = channelColor,
|
||||
maxLines = 1
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,33 +1,36 @@
|
||||
package com.bitchat.android.ui
|
||||
|
||||
import com.bitchat.android.ui.theme.BitchatFontFamily
|
||||
// [Goose] Bridge file share events to ViewModel via dispatcher is installed in ChatScreen composition
|
||||
|
||||
// [Goose] Installing FileShareDispatcher handler in ChatScreen to forward file sends to ViewModel
|
||||
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowDownward
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowDownward
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.TextRange
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.zIndex
|
||||
import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.Lifecycle
|
||||
@ -42,6 +45,7 @@ import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.nostr.LocationNotesManager
|
||||
import com.bitchat.android.nostr.NearbyNotesController
|
||||
import com.bitchat.android.ui.media.FullScreenImageViewer
|
||||
import com.bitchat.android.ui.theme.BitchatMotion
|
||||
|
||||
/**
|
||||
* Main ChatScreen - REFACTORED to use component-based architecture
|
||||
@ -58,6 +62,8 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val messages by viewModel.messages.collectAsStateWithLifecycle()
|
||||
val connectedPeers by viewModel.connectedPeers.collectAsStateWithLifecycle()
|
||||
val peerNicknames by viewModel.peerNicknames.collectAsStateWithLifecycle()
|
||||
val geohashPeople by viewModel.geohashPeople.collectAsStateWithLifecycle()
|
||||
val nickname by viewModel.nickname.collectAsStateWithLifecycle()
|
||||
val selectedPrivatePeer by viewModel.selectedPrivateChatPeer.collectAsStateWithLifecycle()
|
||||
val currentChannel by viewModel.currentChannel.collectAsStateWithLifecycle()
|
||||
@ -179,6 +185,47 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
||||
}
|
||||
}
|
||||
|
||||
// Identity of the timeline on screen, derived exactly like displayMessages above. Drives the
|
||||
// per-conversation scroll position and animation state in MessagesList.
|
||||
val conversationKey = when {
|
||||
currentChannel != null -> "channel:$currentChannel"
|
||||
else -> {
|
||||
val locationChannel = selectedLocationChannel
|
||||
if (locationChannel is com.bitchat.android.geohash.ChannelID.Location) {
|
||||
"geo:${locationChannel.channel.geohash}"
|
||||
} else {
|
||||
"mesh"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val mentionPeerIdentities = remember(
|
||||
displayMessages,
|
||||
currentChannel,
|
||||
selectedLocationChannel,
|
||||
connectedPeers,
|
||||
peerNicknames,
|
||||
geohashPeople,
|
||||
) {
|
||||
val knownPeers = if (
|
||||
currentChannel == null && selectedLocationChannel is ChannelID.Location
|
||||
) {
|
||||
val duplicateNames = duplicateGeohashBaseNames(geohashPeople)
|
||||
geohashPeople.mapNotNull { person ->
|
||||
if (isUnannouncedNickname(person.displayName)) return@mapNotNull null
|
||||
val displayName = disambiguatedGeohashDisplayName(person, duplicateNames)
|
||||
displayName to PeerIdentity.nostr(person.id)
|
||||
}
|
||||
} else {
|
||||
connectedPeers.mapNotNull { peerID ->
|
||||
peerNicknames[peerID]?.let { displayName ->
|
||||
displayName to PeerIdentity.mesh(peerID)
|
||||
}
|
||||
}
|
||||
}
|
||||
buildMentionPeerIdentityMap(displayMessages, knownPeers)
|
||||
}
|
||||
|
||||
// Determine whether to show media buttons (only hide in geohash location chats)
|
||||
val showMediaButtons = when {
|
||||
currentChannel != null -> true
|
||||
@ -191,8 +238,15 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
||||
.fillMaxSize()
|
||||
.background(colorScheme.background) // Extend background to fill entire screen including status bar
|
||||
) {
|
||||
val headerHeight = 42.dp
|
||||
|
||||
val headerHeight = ChatHeaderHeight
|
||||
val statusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
|
||||
|
||||
// Both bars are translucent and the conversation scrolls underneath them, so their
|
||||
// heights are reserved as list padding instead of as layout space. The composer's height
|
||||
// varies (suggestion rows, wrapped lines), so it is measured rather than assumed.
|
||||
var composerHeight by remember { mutableStateOf(0.dp) }
|
||||
val density = LocalDensity.current
|
||||
|
||||
// Main content area that responds to keyboard/window insets
|
||||
Column(
|
||||
modifier = Modifier
|
||||
@ -200,100 +254,92 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
||||
.windowInsetsPadding(WindowInsets.ime) // This handles keyboard insets
|
||||
.windowInsetsPadding(WindowInsets.navigationBars) // Add bottom padding when keyboard is not expanded
|
||||
) {
|
||||
// Header spacer - creates exact space for the floating header (status bar + compact header)
|
||||
Spacer(
|
||||
modifier = Modifier
|
||||
.windowInsetsPadding(WindowInsets.statusBars)
|
||||
.height(headerHeight)
|
||||
Box(modifier = Modifier.weight(1f)) {
|
||||
// Messages area - takes up available space, will compress when keyboard appears
|
||||
// Nearby-notes strip and the reveal hint both live in this Box alongside the
|
||||
// list, rather than in a Column above it, because the conversation has to scroll
|
||||
// underneath the translucent bars. Their heights are reserved as list padding.
|
||||
var notesStripHeight by remember { mutableStateOf(0.dp) }
|
||||
val showNotesStrip =
|
||||
isMeshTimeline && nearbyNotesRevealed && nearbyNotes.isNotEmpty()
|
||||
|
||||
MessagesList(
|
||||
messages = displayMessages,
|
||||
currentUserNickname = nickname,
|
||||
meshService = viewModel.meshServiceFacade,
|
||||
mentionPeerIdentities = mentionPeerIdentities,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
conversationKey = conversationKey,
|
||||
contentPadding = PaddingValues(
|
||||
top = statusBarHeight + headerHeight +
|
||||
(if (showNotesStrip) notesStripHeight else 0.dp),
|
||||
bottom = composerHeight
|
||||
),
|
||||
forceScrollToBottom = forceScrollToBottom,
|
||||
onScrolledUpChanged = { isUp -> isScrolledUp = isUp },
|
||||
onNicknameClick = { fullSenderName ->
|
||||
// Single click - mention user in text input
|
||||
val currentText = messageText.text
|
||||
|
||||
// Extract base nickname and hash suffix from full sender name
|
||||
val (baseName, hashSuffix) = splitSuffix(fullSenderName)
|
||||
|
||||
// Check if we're in a geohash channel to include hash suffix
|
||||
val selectedLocationChannel = viewModel.selectedLocationChannel.value
|
||||
val mentionText = if (
|
||||
selectedLocationChannel is ChannelID.Location &&
|
||||
hashSuffix.isNotEmpty()
|
||||
) {
|
||||
// In geohash chat - include the hash suffix from the full display name
|
||||
"@$baseName$hashSuffix"
|
||||
} else {
|
||||
// Regular chat - just the base nickname
|
||||
"@$baseName"
|
||||
}
|
||||
|
||||
val newText = when {
|
||||
currentText.isEmpty() -> "$mentionText "
|
||||
currentText.endsWith(" ") -> "$currentText$mentionText "
|
||||
else -> "$currentText $mentionText "
|
||||
}
|
||||
|
||||
messageText = TextFieldValue(
|
||||
text = newText,
|
||||
selection = TextRange(newText.length)
|
||||
)
|
||||
},
|
||||
onMessageLongPress = { message ->
|
||||
// Message long press - open user action sheet with message context
|
||||
// Extract base nickname from message sender (contains all necessary info)
|
||||
val (baseName, _) = splitSuffix(message.sender)
|
||||
selectedUserForSheet = baseName
|
||||
selectedMessageForSheet = message
|
||||
showUserSheet = true
|
||||
},
|
||||
onCancelTransfer = { msg ->
|
||||
viewModel.cancelMediaSend(msg.id)
|
||||
},
|
||||
onImageClick = { currentPath, allImagePaths, initialIndex ->
|
||||
viewerImagePaths = allImagePaths
|
||||
initialViewerIndex = initialIndex
|
||||
showFullScreenImageViewer = true
|
||||
}
|
||||
)
|
||||
|
||||
// Messages area - takes up available space, will compress when keyboard appears
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
if (isMeshTimeline && nearbyNotesRevealed && nearbyNotes.isNotEmpty()) {
|
||||
NearbyNotesStrip(
|
||||
noteCount = nearbyNotes.size,
|
||||
onClick = { showLocationNotesSheet = true },
|
||||
)
|
||||
}
|
||||
|
||||
Box(
|
||||
if (showNotesStrip) {
|
||||
NearbyNotesStrip(
|
||||
noteCount = nearbyNotes.size,
|
||||
onClick = { showLocationNotesSheet = true },
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth(),
|
||||
) {
|
||||
MessagesList(
|
||||
messages = displayMessages,
|
||||
currentUserNickname = nickname,
|
||||
meshService = viewModel.meshServiceFacade,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
forceScrollToBottom = forceScrollToBottom,
|
||||
onScrolledUpChanged = { isUp -> isScrolledUp = isUp },
|
||||
onNicknameClick = { fullSenderName ->
|
||||
// Single click - mention user in text input
|
||||
val currentText = messageText.text
|
||||
|
||||
// Extract base nickname and hash suffix from full sender name
|
||||
val (baseName, hashSuffix) = splitSuffix(fullSenderName)
|
||||
|
||||
// Check if we're in a geohash channel to include hash suffix
|
||||
val selectedLocationChannel = viewModel.selectedLocationChannel.value
|
||||
val mentionText = if (
|
||||
selectedLocationChannel is ChannelID.Location &&
|
||||
hashSuffix.isNotEmpty()
|
||||
) {
|
||||
// In geohash chat - include the hash suffix from the full display name
|
||||
"@$baseName$hashSuffix"
|
||||
} else {
|
||||
// Regular chat - just the base nickname
|
||||
"@$baseName"
|
||||
}
|
||||
|
||||
val newText = when {
|
||||
currentText.isEmpty() -> "$mentionText "
|
||||
currentText.endsWith(" ") -> "$currentText$mentionText "
|
||||
else -> "$currentText $mentionText "
|
||||
}
|
||||
|
||||
messageText = TextFieldValue(
|
||||
text = newText,
|
||||
selection = TextRange(newText.length),
|
||||
)
|
||||
.align(Alignment.TopCenter)
|
||||
.padding(top = statusBarHeight + headerHeight)
|
||||
.onSizeChanged { size ->
|
||||
notesStripHeight = with(density) { size.height.toDp() }
|
||||
},
|
||||
onMessageLongPress = { message ->
|
||||
// Message long press - open user action sheet with message context
|
||||
// Extract base nickname from message sender (contains all necessary info)
|
||||
val (baseName, _) = splitSuffix(message.sender)
|
||||
selectedUserForSheet = baseName
|
||||
selectedMessageForSheet = message
|
||||
showUserSheet = true
|
||||
},
|
||||
onCancelTransfer = { msg ->
|
||||
viewModel.cancelMediaSend(msg.id)
|
||||
},
|
||||
onImageClick = { currentPath, allImagePaths, initialIndex ->
|
||||
viewerImagePaths = allImagePaths
|
||||
initialViewerIndex = initialIndex
|
||||
showFullScreenImageViewer = true
|
||||
},
|
||||
)
|
||||
|
||||
if (
|
||||
displayMessages.isEmpty() &&
|
||||
isMeshTimeline &&
|
||||
!nearbyNotesRevealed &&
|
||||
locationEnabled &&
|
||||
locationPermissionState ==
|
||||
LocationChannelManager.PermissionState.AUTHORIZED &&
|
||||
buildingGeohash != null
|
||||
) {
|
||||
NearbyNotesRevealHint(
|
||||
onClick = nearbyNotesController::reveal,
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
// Input area - stays at bottom
|
||||
|
||||
// Input area - overlays the bottom of the conversation
|
||||
// Bridge file share from lower-level input to ViewModel
|
||||
androidx.compose.runtime.LaunchedEffect(Unit) {
|
||||
com.bitchat.android.ui.events.FileShareDispatcher.setHandler { peer, channel, path ->
|
||||
@ -302,6 +348,11 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
||||
}
|
||||
|
||||
ChatInputSection(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.onSizeChanged { size ->
|
||||
composerHeight = with(density) { size.height.toDp() }
|
||||
},
|
||||
messageText = messageText,
|
||||
onMessageTextChange = { newText: TextFieldValue ->
|
||||
messageText = newText
|
||||
@ -329,6 +380,7 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
||||
commandSuggestions = commandSuggestions,
|
||||
showMentionSuggestions = showMentionSuggestions,
|
||||
mentionSuggestions = mentionSuggestions,
|
||||
mentionPeerIdentities = mentionPeerIdentities,
|
||||
onCommandSuggestionClick = { suggestion: CommandSuggestion ->
|
||||
val commandText = viewModel.selectCommandSuggestion(suggestion)
|
||||
messageText = TextFieldValue(
|
||||
@ -349,11 +401,11 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
||||
colorScheme = colorScheme,
|
||||
showMediaButtons = showMediaButtons
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Floating header - positioned absolutely at top, ignores keyboard
|
||||
ChatFloatingHeader(
|
||||
headerHeight = headerHeight,
|
||||
selectedPrivatePeer = null,
|
||||
currentChannel = currentChannel,
|
||||
nickname = nickname,
|
||||
@ -369,40 +421,39 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
||||
}
|
||||
)
|
||||
|
||||
// Divider under header - positioned after status bar + header height
|
||||
HorizontalDivider(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.windowInsetsPadding(WindowInsets.statusBars)
|
||||
.offset(y = headerHeight)
|
||||
.zIndex(1f),
|
||||
color = colorScheme.outline.copy(alpha = 0.3f)
|
||||
)
|
||||
|
||||
// Scroll-to-bottom floating button
|
||||
AnimatedVisibility(
|
||||
visible = isScrolledUp,
|
||||
enter = slideInVertically(initialOffsetY = { it / 2 }) + fadeIn(),
|
||||
exit = slideOutVertically(targetOffsetY = { it / 2 }) + fadeOut(),
|
||||
// Short and eased: the button appears mid-scroll, so a slow entrance draws the eye
|
||||
// away from the messages the user is actually reading.
|
||||
enter = slideInVertically(
|
||||
animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing),
|
||||
initialOffsetY = { it / 2 }
|
||||
) + fadeIn(tween(BitchatMotion.STANDARD_MS)),
|
||||
exit = slideOutVertically(
|
||||
animationSpec = tween(BitchatMotion.QUICK_MS, easing = FastOutSlowInEasing),
|
||||
targetOffsetY = { it / 2 }
|
||||
) + fadeOut(tween(BitchatMotion.QUICK_MS)),
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(end = 16.dp, bottom = 64.dp)
|
||||
.padding(end = 16.dp, bottom = composerHeight + 8.dp)
|
||||
.zIndex(1.5f)
|
||||
.windowInsetsPadding(WindowInsets.navigationBars)
|
||||
.windowInsetsPadding(WindowInsets.ime)
|
||||
) {
|
||||
Surface(
|
||||
shape = CircleShape,
|
||||
color = colorScheme.background,
|
||||
color = colorScheme.surface,
|
||||
tonalElevation = 3.dp,
|
||||
shadowElevation = 6.dp,
|
||||
border = BorderStroke(2.dp, Color(0xFF00C851))
|
||||
border = BorderStroke(1.dp, colorScheme.primary)
|
||||
) {
|
||||
IconButton(onClick = { forceScrollToBottom = !forceScrollToBottom }) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.ArrowDownward,
|
||||
contentDescription = stringResource(com.bitchat.android.R.string.cd_scroll_to_bottom),
|
||||
tint = Color(0xFF00C851)
|
||||
modifier = Modifier.size(22.dp),
|
||||
tint = colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -487,30 +538,6 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NearbyNotesRevealHint(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val actionLabel = stringResource(R.string.nearby_notes_reveal)
|
||||
TextButton(
|
||||
onClick = onClick,
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 48.dp)
|
||||
.padding(horizontal = 24.dp)
|
||||
.semantics { contentDescription = actionLabel },
|
||||
) {
|
||||
Text(
|
||||
text = "📍 $actionLabel",
|
||||
modifier = Modifier.clearAndSetSemantics { },
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NearbyNotesStrip(
|
||||
noteCount: Int,
|
||||
@ -537,7 +564,7 @@ private fun NearbyNotesStrip(
|
||||
},
|
||||
modifier = Modifier.weight(1f),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
Text(
|
||||
@ -561,58 +588,115 @@ fun ChatInputSection(
|
||||
commandSuggestions: List<CommandSuggestion>,
|
||||
showMentionSuggestions: Boolean,
|
||||
mentionSuggestions: List<String>,
|
||||
mentionPeerIdentities: Map<String, PeerIdentity> = emptyMap(),
|
||||
onCommandSuggestionClick: (CommandSuggestion) -> Unit,
|
||||
onMentionSuggestionClick: (String) -> Unit,
|
||||
selectedPrivatePeer: String?,
|
||||
currentChannel: String?,
|
||||
nickname: String,
|
||||
colorScheme: ColorScheme,
|
||||
showMediaButtons: Boolean
|
||||
showMediaButtons: Boolean,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = colorScheme.background
|
||||
Column(
|
||||
// Flat, slightly translucent screen background — the same treatment as the top bar, so the
|
||||
// two bars are visibly the same kind of surface. No gradient: a soft ramp here just looked
|
||||
// like a smudge above a crisp hairline. The rule is inside the background so the whole bar
|
||||
// is one surface with a top border, rather than a line floating over the conversation.
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.background(colorScheme.background.copy(alpha = BarBackgroundAlpha))
|
||||
) {
|
||||
Column {
|
||||
HorizontalDivider(color = colorScheme.outline.copy(alpha = 0.3f))
|
||||
// Command suggestions box
|
||||
if (showCommandSuggestions && commandSuggestions.isNotEmpty()) {
|
||||
CommandSuggestionsBox(
|
||||
suggestions = commandSuggestions,
|
||||
onSuggestionClick = onCommandSuggestionClick,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
HorizontalDivider(color = colorScheme.outline.copy(alpha = 0.2f))
|
||||
// Hairline marking where chrome begins. Faint on purpose — it is a hint, not a border.
|
||||
HorizontalDivider(thickness = 1.dp, color = colorScheme.outlineVariant)
|
||||
|
||||
// Command suggestions box
|
||||
if (showCommandSuggestions && commandSuggestions.isNotEmpty()) {
|
||||
CommandSuggestionsBox(
|
||||
suggestions = commandSuggestions,
|
||||
onSuggestionClick = onCommandSuggestionClick,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
HorizontalDivider(thickness = 1.dp, color = colorScheme.outlineVariant)
|
||||
}
|
||||
// Retain the final populated list while the picker exits. The state layer clears
|
||||
// suggestions together with visibility; without this snapshot the panel would empty and
|
||||
// snap shut before its shrink/fade animation had a frame to run.
|
||||
var retainedMentionSuggestions by remember { mutableStateOf(emptyList<String>()) }
|
||||
LaunchedEffect(mentionSuggestions) {
|
||||
if (mentionSuggestions.isNotEmpty()) {
|
||||
retainedMentionSuggestions = mentionSuggestions
|
||||
}
|
||||
// Mention suggestions box
|
||||
if (showMentionSuggestions && mentionSuggestions.isNotEmpty()) {
|
||||
}
|
||||
val mentionPickerVisible = showMentionSuggestions && mentionSuggestions.isNotEmpty()
|
||||
val displayedMentionSuggestions = mentionSuggestions.ifEmpty {
|
||||
retainedMentionSuggestions
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = mentionPickerVisible,
|
||||
enter = fadeIn(tween(BitchatMotion.STANDARD_MS)) +
|
||||
expandVertically(
|
||||
animationSpec = tween(
|
||||
BitchatMotion.STANDARD_MS,
|
||||
easing = FastOutSlowInEasing
|
||||
),
|
||||
expandFrom = Alignment.Bottom
|
||||
),
|
||||
exit = fadeOut(tween(BitchatMotion.QUICK_MS)) +
|
||||
shrinkVertically(
|
||||
animationSpec = tween(
|
||||
BitchatMotion.QUICK_MS,
|
||||
easing = FastOutSlowInEasing
|
||||
),
|
||||
shrinkTowards = Alignment.Bottom
|
||||
)
|
||||
) {
|
||||
Column {
|
||||
MentionSuggestionsBox(
|
||||
suggestions = mentionSuggestions,
|
||||
suggestions = displayedMentionSuggestions,
|
||||
mentionPeerIdentities = mentionPeerIdentities,
|
||||
onSuggestionClick = onMentionSuggestionClick,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
HorizontalDivider(color = colorScheme.outline.copy(alpha = 0.2f))
|
||||
HorizontalDivider(thickness = 1.dp, color = colorScheme.outlineVariant)
|
||||
}
|
||||
MessageInput(
|
||||
value = messageText,
|
||||
onValueChange = onMessageTextChange,
|
||||
onSend = onSend,
|
||||
onSendVoiceNote = onSendVoiceNote,
|
||||
onSendImageNote = onSendImageNote,
|
||||
onSendFileNote = onSendFileNote,
|
||||
selectedPrivatePeer = selectedPrivatePeer,
|
||||
currentChannel = currentChannel,
|
||||
nickname = nickname,
|
||||
showMediaButtons = showMediaButtons,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
MessageInput(
|
||||
value = messageText,
|
||||
onValueChange = onMessageTextChange,
|
||||
onSend = onSend,
|
||||
onSendVoiceNote = onSendVoiceNote,
|
||||
onSendImageNote = onSendImageNote,
|
||||
onSendFileNote = onSendFileNote,
|
||||
selectedPrivatePeer = selectedPrivatePeer,
|
||||
currentChannel = currentChannel,
|
||||
nickname = nickname,
|
||||
showMediaButtons = showMediaButtons,
|
||||
mentionPeerIdentities = mentionPeerIdentities,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
|
||||
/**
|
||||
* Opacity shared by both bars.
|
||||
*
|
||||
* Slight, so the conversation scrolling underneath stays faintly perceptible and the chrome reads
|
||||
* as sitting over the content rather than boxing it in — without ever costing legibility.
|
||||
*/
|
||||
private const val BarBackgroundAlpha = 0.88f
|
||||
|
||||
/**
|
||||
* Fraction of the header that stays fully opaque, measured from the top.
|
||||
*
|
||||
* The header is the one place a gradient earns its keep: the status bar is transparent, so the
|
||||
* header has to be the true background colour where the two meet or the system bar stops looking
|
||||
* like part of the app. Everything below that stop matches the composer's flat translucency.
|
||||
*/
|
||||
private const val HeaderOpaqueStop = 0.72f
|
||||
@Composable
|
||||
private fun ChatFloatingHeader(
|
||||
headerHeight: Dp,
|
||||
selectedPrivatePeer: String?,
|
||||
currentChannel: String?,
|
||||
nickname: String,
|
||||
@ -626,42 +710,48 @@ private fun ChatFloatingHeader(
|
||||
) {
|
||||
val context = androidx.compose.ui.platform.LocalContext.current
|
||||
val locationManager = remember { com.bitchat.android.geohash.LocationChannelManager.getInstance(context) }
|
||||
|
||||
Surface(
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.zIndex(1f)
|
||||
.windowInsetsPadding(WindowInsets.statusBars), // Extend into status bar area
|
||||
color = colorScheme.background // Solid background color extending into status bar
|
||||
) {
|
||||
TopAppBar(
|
||||
title = {
|
||||
ChatHeaderContent(
|
||||
selectedPrivatePeer = selectedPrivatePeer,
|
||||
currentChannel = currentChannel,
|
||||
nickname = nickname,
|
||||
viewModel = viewModel,
|
||||
onBackClick = {
|
||||
when {
|
||||
selectedPrivatePeer != null -> viewModel.endPrivateChat()
|
||||
currentChannel != null -> viewModel.switchToChannel(null)
|
||||
}
|
||||
},
|
||||
onSidebarClick = onSidebarToggle,
|
||||
onTripleClick = onPanicClear,
|
||||
onShowAppInfo = onShowAppInfo,
|
||||
onLocationChannelsClick = onLocationChannelsClick,
|
||||
onLocationNotesClick = {
|
||||
// Ensure location is loaded before showing sheet
|
||||
locationManager.refreshChannels()
|
||||
onLocationNotesClick()
|
||||
}
|
||||
// Fully opaque where it meets the system status bar, fading to translucent at its
|
||||
// lower edge. The status bar itself is transparent, so anything less than opaque at
|
||||
// the top would let the wallpaper or a light system-bar scrim bleed through and the
|
||||
// header would stop reading as part of the app.
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
0f to colorScheme.background,
|
||||
HeaderOpaqueStop to colorScheme.background,
|
||||
1f to colorScheme.background.copy(alpha = BarBackgroundAlpha)
|
||||
)
|
||||
)
|
||||
.windowInsetsPadding(WindowInsets.statusBars) // Extend into status bar area
|
||||
) {
|
||||
// No TopAppBar: it silently injects a 4.dp horizontal pad plus a 12.dp title inset and
|
||||
// applies its own minimum heights, which made the header's spacing impossible to specify
|
||||
// exactly. Height and edge insets belong to each header variant, so that a conversation
|
||||
// header rendered here and one rendered in a sheet are laid out identically.
|
||||
ChatHeaderContent(
|
||||
selectedPrivatePeer = selectedPrivatePeer,
|
||||
currentChannel = currentChannel,
|
||||
nickname = nickname,
|
||||
viewModel = viewModel,
|
||||
onBackClick = {
|
||||
when {
|
||||
selectedPrivatePeer != null -> viewModel.endPrivateChat()
|
||||
currentChannel != null -> viewModel.switchToChannel(null)
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = Color.Transparent
|
||||
),
|
||||
modifier = Modifier.height(headerHeight) // Ensure compact header height
|
||||
onSidebarClick = onSidebarToggle,
|
||||
onTripleClick = onPanicClear,
|
||||
onShowAppInfo = onShowAppInfo,
|
||||
onLocationChannelsClick = onLocationChannelsClick,
|
||||
onLocationNotesClick = {
|
||||
// Ensure location is loaded before showing sheet
|
||||
locationManager.refreshChannels()
|
||||
onLocationNotesClick()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,9 +7,10 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.mesh.MeshService
|
||||
import androidx.compose.material3.ColorScheme
|
||||
import com.bitchat.android.ui.theme.BASE_FONT_SIZE
|
||||
import com.bitchat.android.ui.theme.BitchatPalette
|
||||
import com.bitchat.android.ui.theme.ChatVisualTokens
|
||||
import com.bitchat.android.ui.theme.colorForPeer
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
@ -18,6 +19,27 @@ import java.util.*
|
||||
* Extracted from ChatScreen.kt for better organization
|
||||
*/
|
||||
|
||||
/** Opacity applied to the `#abcd` disambiguation suffix so the readable name dominates. */
|
||||
internal const val SUFFIX_ALPHA = ChatVisualTokens.SenderSuffixAlpha
|
||||
|
||||
/** Compact transcript timestamp; seconds add noise without helping conversation scanning. */
|
||||
internal const val CHAT_TIMESTAMP_PATTERN = "HH:mm"
|
||||
|
||||
/** Background opacity for a mention chip referring to somebody else. */
|
||||
internal const val MENTION_CHIP_ALPHA = ChatVisualTokens.HighlightAlpha
|
||||
|
||||
/** Background opacity for a mention chip referring to you. Slightly stronger to catch the eye. */
|
||||
internal const val MENTION_CHIP_ALPHA_SELF = ChatVisualTokens.HighlightAlpha
|
||||
|
||||
/**
|
||||
* Mention token grammar shared by rendered messages and the composer.
|
||||
*
|
||||
* The optional `#abcd` suffix is part of the mention because it disambiguates peers that use the
|
||||
* same nickname. Keeping one regex prevents the composer from styling only `@name` while the
|
||||
* rendered transcript styles the complete token.
|
||||
*/
|
||||
internal val MENTION_TOKEN_REGEX = Regex("@([\\p{L}0-9_]+(?:#[a-fA-F0-9]{4})?)")
|
||||
|
||||
/**
|
||||
* Get RSSI-based color for signal strength visualization
|
||||
*/
|
||||
@ -32,152 +54,31 @@ fun getRSSIColor(rssi: Int): Color {
|
||||
}
|
||||
|
||||
/**
|
||||
* Format message as annotated string with iOS-style formatting
|
||||
* Timestamp at END, peer colors, hashtag suffix handling
|
||||
*/
|
||||
fun formatMessageAsAnnotatedString(
|
||||
message: BitchatMessage,
|
||||
currentUserNickname: String,
|
||||
meshService: MeshService,
|
||||
colorScheme: ColorScheme,
|
||||
timeFormatter: SimpleDateFormat = SimpleDateFormat("HH:mm:ss", Locale.getDefault())
|
||||
): AnnotatedString {
|
||||
val builder = AnnotatedString.Builder()
|
||||
val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
|
||||
|
||||
// Determine if this message was sent by self
|
||||
val isSelf = message.isFromSelf(currentUserNickname, meshService.myPeerID)
|
||||
|
||||
if (message.sender != "system") {
|
||||
// Get base color for this peer (iOS-style color assignment)
|
||||
val baseColor = if (isSelf) {
|
||||
Color(0xFFFF9500) // Orange for self (iOS orange)
|
||||
} else {
|
||||
getPeerColor(message, isDark)
|
||||
}
|
||||
|
||||
// Split sender into base name and hashtag suffix
|
||||
val (baseName, suffix) = splitSuffix(message.sender)
|
||||
|
||||
// Sender prefix "<@"
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = baseColor,
|
||||
fontSize = BASE_FONT_SIZE.sp,
|
||||
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Medium
|
||||
))
|
||||
builder.append("<@")
|
||||
builder.pop()
|
||||
|
||||
// Base name (clickable)
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = baseColor,
|
||||
fontSize = BASE_FONT_SIZE.sp,
|
||||
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Medium
|
||||
))
|
||||
val nicknameStart = builder.length
|
||||
val truncatedBase = truncateNickname(baseName)
|
||||
builder.append(truncatedBase)
|
||||
val nicknameEnd = builder.length
|
||||
|
||||
// Add click annotation for nickname (store canonical sender name with hash if available)
|
||||
if (!isSelf) {
|
||||
builder.addStringAnnotation(
|
||||
tag = "nickname_click",
|
||||
annotation = (message.originalSender ?: message.sender),
|
||||
start = nicknameStart,
|
||||
end = nicknameEnd
|
||||
)
|
||||
}
|
||||
builder.pop()
|
||||
|
||||
// Hashtag suffix in lighter color (iOS style)
|
||||
if (suffix.isNotEmpty()) {
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = baseColor.copy(alpha = 0.6f),
|
||||
fontSize = BASE_FONT_SIZE.sp,
|
||||
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Medium
|
||||
))
|
||||
builder.append(suffix)
|
||||
builder.pop()
|
||||
}
|
||||
|
||||
// Sender suffix "> "
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = baseColor,
|
||||
fontSize = BASE_FONT_SIZE.sp,
|
||||
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Medium
|
||||
))
|
||||
builder.append("> ")
|
||||
builder.pop()
|
||||
|
||||
// Message content with iOS-style hashtag and mention highlighting
|
||||
appendIOSFormattedContent(
|
||||
builder,
|
||||
message.content,
|
||||
message.mentions,
|
||||
currentUserNickname,
|
||||
baseColor,
|
||||
isSelf,
|
||||
)
|
||||
|
||||
// iOS-style timestamp at the END (smaller, grey)
|
||||
// Timestamp (and optional PoW badge)
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = Color.Gray.copy(alpha = 0.7f),
|
||||
fontSize = (BASE_FONT_SIZE - 4).sp
|
||||
))
|
||||
builder.append(" [${timeFormatter.format(message.timestamp)}]")
|
||||
// If message has valid PoW difficulty, append bits immediately after timestamp with minimal spacing
|
||||
message.powDifficulty?.let { bits ->
|
||||
if (bits > 0) {
|
||||
builder.append(" ⛨${bits}b")
|
||||
}
|
||||
}
|
||||
builder.pop()
|
||||
|
||||
} else {
|
||||
// System message - iOS style
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = Color.Gray,
|
||||
fontSize = (BASE_FONT_SIZE - 2).sp,
|
||||
fontStyle = androidx.compose.ui.text.font.FontStyle.Italic
|
||||
))
|
||||
builder.append("* ${message.content} *")
|
||||
builder.pop()
|
||||
|
||||
// Timestamp for system messages too
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = Color.Gray.copy(alpha = 0.5f),
|
||||
fontSize = (BASE_FONT_SIZE - 4).sp
|
||||
))
|
||||
builder.append(" [${timeFormatter.format(message.timestamp)}]")
|
||||
builder.pop()
|
||||
}
|
||||
|
||||
return builder.toAnnotatedString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the sender label used by the two-row text-message layout.
|
||||
* Build the sender label shown above the first message of a group.
|
||||
*
|
||||
* Renders `@name` plus a dimmed `#abcd` suffix. The name carries a `nickname_click`
|
||||
* annotation for everyone except yourself.
|
||||
*/
|
||||
fun formatTextMessageSender(
|
||||
message: BitchatMessage,
|
||||
currentUserNickname: String,
|
||||
meshService: MeshService,
|
||||
colorScheme: ColorScheme
|
||||
myPeerID: String,
|
||||
palette: BitchatPalette
|
||||
): AnnotatedString {
|
||||
val builder = AnnotatedString.Builder()
|
||||
val isDark =
|
||||
colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
|
||||
val isSelf = message.isFromSelf(currentUserNickname, meshService.myPeerID)
|
||||
val senderColor = if (isSelf) Color(0xFFFF9500) else getPeerColor(message, isDark)
|
||||
val senderWeight = if (isSelf) FontWeight.Bold else FontWeight.Medium
|
||||
val isSelf = message.isFromSelf(currentUserNickname, myPeerID)
|
||||
val senderColor = if (isSelf) {
|
||||
palette.accentOrange
|
||||
} else {
|
||||
colorForPeer(peerIdentityForMessage(message), palette)
|
||||
}
|
||||
val senderWeight = FontWeight.SemiBold
|
||||
val (baseName, suffix) = splitSuffix(message.sender)
|
||||
|
||||
builder.pushStyle(
|
||||
SpanStyle(
|
||||
color = senderColor,
|
||||
fontSize = BASE_FONT_SIZE.sp,
|
||||
fontSize = ChatVisualTokens.SenderFontSize,
|
||||
fontWeight = senderWeight
|
||||
)
|
||||
)
|
||||
@ -198,9 +99,9 @@ fun formatTextMessageSender(
|
||||
if (suffix.isNotEmpty()) {
|
||||
builder.pushStyle(
|
||||
SpanStyle(
|
||||
color = senderColor.copy(alpha = 0.6f),
|
||||
fontSize = BASE_FONT_SIZE.sp,
|
||||
fontWeight = senderWeight
|
||||
color = senderColor.copy(alpha = SUFFIX_ALPHA),
|
||||
fontSize = ChatVisualTokens.SenderFontSize,
|
||||
fontWeight = FontWeight.Normal
|
||||
)
|
||||
)
|
||||
builder.append(suffix)
|
||||
@ -212,10 +113,13 @@ fun formatTextMessageSender(
|
||||
|
||||
/**
|
||||
* Build the compact timestamp and optional proof-of-work label.
|
||||
*
|
||||
* Used standalone by media rows; text messages get the same span appended inline to the end of
|
||||
* their body via [appendBodyTimestamp].
|
||||
*/
|
||||
fun formatTextMessageMetadata(
|
||||
message: BitchatMessage,
|
||||
timeFormatter: SimpleDateFormat = SimpleDateFormat("HH:mm:ss", Locale.getDefault())
|
||||
timeFormatter: SimpleDateFormat = SimpleDateFormat(CHAT_TIMESTAMP_PATTERN, Locale.getDefault())
|
||||
): AnnotatedString {
|
||||
val builder = AnnotatedString.Builder()
|
||||
builder.pushStyle(
|
||||
@ -233,66 +137,159 @@ fun formatTextMessageMetadata(
|
||||
}
|
||||
|
||||
/**
|
||||
* Build only the message body while retaining mention, URL and geohash styling.
|
||||
* Append the timestamp (and optional PoW difficulty) directly after the message body so it
|
||||
* trails the final words rather than occupying its own column.
|
||||
*
|
||||
* Deliberately carries no click annotation: the timestamp is decoration, and making it
|
||||
* tappable would create dead zones inside the message body.
|
||||
*/
|
||||
private fun appendTimestampText(
|
||||
builder: AnnotatedString.Builder,
|
||||
message: BitchatMessage,
|
||||
timeFormatter: SimpleDateFormat
|
||||
) {
|
||||
builder.append(" ")
|
||||
builder.append(timeFormatter.format(message.timestamp))
|
||||
message.powDifficulty?.takeIf { it > 0 }?.let { bits ->
|
||||
builder.append(" ⛨${bits}b")
|
||||
}
|
||||
}
|
||||
|
||||
private fun appendBodyTimestamp(
|
||||
builder: AnnotatedString.Builder,
|
||||
message: BitchatMessage,
|
||||
palette: BitchatPalette,
|
||||
timeFormatter: SimpleDateFormat,
|
||||
) {
|
||||
builder.pushStyle(
|
||||
SpanStyle(
|
||||
color = palette.textTertiary,
|
||||
fontSize = ChatVisualTokens.SystemTimeFontSize,
|
||||
fontWeight = FontWeight.Normal,
|
||||
)
|
||||
)
|
||||
appendTimestampText(builder, message, timeFormatter)
|
||||
builder.pop()
|
||||
}
|
||||
|
||||
private fun appendMutedTimestamp(
|
||||
builder: AnnotatedString.Builder,
|
||||
message: BitchatMessage,
|
||||
contentColor: Color,
|
||||
timeFormatter: SimpleDateFormat,
|
||||
) {
|
||||
builder.pushStyle(
|
||||
SpanStyle(
|
||||
color = contentColor.copy(alpha = ChatVisualTokens.MutedTextAlpha),
|
||||
fontSize = ChatVisualTokens.SystemTimeFontSize,
|
||||
fontWeight = FontWeight.Normal,
|
||||
)
|
||||
)
|
||||
appendTimestampText(builder, message, timeFormatter)
|
||||
builder.pop()
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the message body: neutral text with mention/URL/geohash accents, followed by an inline
|
||||
* trailing timestamp.
|
||||
*
|
||||
* Body text is intentionally neutral rather than peer-colored. Colour is reserved for
|
||||
* `@names`, which is what makes a busy channel scannable.
|
||||
*/
|
||||
fun formatTextMessageBody(
|
||||
message: BitchatMessage,
|
||||
currentUserNickname: String,
|
||||
meshService: MeshService,
|
||||
colorScheme: ColorScheme
|
||||
palette: BitchatPalette,
|
||||
contentColor: Color,
|
||||
linkColor: Color,
|
||||
mentionPeerIdentities: Map<String, PeerIdentity> = emptyMap(),
|
||||
timeFormatter: SimpleDateFormat = SimpleDateFormat(CHAT_TIMESTAMP_PATTERN, Locale.getDefault()),
|
||||
includeTimestamp: Boolean = true
|
||||
): AnnotatedString {
|
||||
val builder = AnnotatedString.Builder()
|
||||
val isDark =
|
||||
colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
|
||||
val isSelf = message.isFromSelf(currentUserNickname, meshService.myPeerID)
|
||||
val accentColor = if (isSelf) Color(0xFFFF9500) else getPeerColor(message, isDark)
|
||||
|
||||
appendIOSFormattedContent(
|
||||
builder = builder,
|
||||
content = message.content,
|
||||
mentions = message.mentions,
|
||||
currentUserNickname = currentUserNickname,
|
||||
baseColor = accentColor,
|
||||
isSelf = isSelf,
|
||||
contentColor = colorScheme.onSurface
|
||||
palette = palette,
|
||||
contentColor = contentColor,
|
||||
linkColor = linkColor,
|
||||
mentionPeerIdentities = mentionPeerIdentities,
|
||||
)
|
||||
|
||||
if (includeTimestamp) {
|
||||
appendBodyTimestamp(builder, message, palette, timeFormatter)
|
||||
}
|
||||
return builder.toAnnotatedString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Build only the nickname + timestamp header line for a message, matching styles of normal messages.
|
||||
* Build a system / background-action line, e.g. `// Tor started. Routing all chats… 11:09:56`.
|
||||
*
|
||||
* The `//` prefix reads as machine narration in a monospace context and is far quieter than
|
||||
* the previous `* italic asterisk *` treatment, which competed with real messages.
|
||||
*/
|
||||
fun formatSystemMessage(
|
||||
message: BitchatMessage,
|
||||
contentColor: Color,
|
||||
timeFormatter: SimpleDateFormat = SimpleDateFormat(CHAT_TIMESTAMP_PATTERN, Locale.getDefault())
|
||||
): AnnotatedString {
|
||||
val builder = AnnotatedString.Builder()
|
||||
builder.pushStyle(
|
||||
SpanStyle(
|
||||
color = contentColor.copy(alpha = ChatVisualTokens.MutedTextAlpha),
|
||||
fontSize = ChatVisualTokens.SystemActionFontSize,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
)
|
||||
builder.append("// ")
|
||||
builder.append(message.content)
|
||||
builder.pop()
|
||||
|
||||
appendMutedTimestamp(builder, message, contentColor, timeFormatter)
|
||||
return builder.toAnnotatedString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Header line for media (image / audio / file) rows.
|
||||
*
|
||||
* Matches the text-message treatment: `@name#abcd` with no angle brackets, followed by an
|
||||
* inline trailing timestamp. Media rows have no body text to trail, so the timestamp sits on
|
||||
* the same line as the name.
|
||||
*/
|
||||
fun formatMessageHeaderAnnotatedString(
|
||||
message: BitchatMessage,
|
||||
currentUserNickname: String,
|
||||
meshService: MeshService,
|
||||
colorScheme: ColorScheme,
|
||||
timeFormatter: SimpleDateFormat = SimpleDateFormat("HH:mm:ss", Locale.getDefault())
|
||||
myPeerID: String,
|
||||
palette: BitchatPalette,
|
||||
contentColor: Color,
|
||||
timeFormatter: SimpleDateFormat = SimpleDateFormat(CHAT_TIMESTAMP_PATTERN, Locale.getDefault()),
|
||||
includeSender: Boolean = true
|
||||
): AnnotatedString {
|
||||
val builder = AnnotatedString.Builder()
|
||||
val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
|
||||
val isSelf = message.isFromSelf(currentUserNickname, myPeerID)
|
||||
|
||||
val isSelf = message.isFromSelf(currentUserNickname, meshService.myPeerID)
|
||||
if (message.sender == "system") {
|
||||
return formatSystemMessage(message, contentColor, timeFormatter)
|
||||
}
|
||||
|
||||
if (message.sender != "system") {
|
||||
val baseColor = if (isSelf) Color(0xFFFF9500) else getPeerColor(message, isDark)
|
||||
if (includeSender) {
|
||||
val baseColor = if (isSelf) {
|
||||
palette.accentOrange
|
||||
} else {
|
||||
colorForPeer(peerIdentityForMessage(message), palette)
|
||||
}
|
||||
val (baseName, suffix) = splitSuffix(message.sender)
|
||||
|
||||
// "<@"
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = baseColor,
|
||||
fontSize = BASE_FONT_SIZE.sp,
|
||||
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Medium
|
||||
))
|
||||
builder.append("<@")
|
||||
builder.pop()
|
||||
|
||||
// Base name (clickable when not self)
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = baseColor,
|
||||
fontSize = BASE_FONT_SIZE.sp,
|
||||
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Medium
|
||||
))
|
||||
builder.pushStyle(
|
||||
SpanStyle(
|
||||
color = baseColor,
|
||||
fontSize = ChatVisualTokens.SenderFontSize,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
)
|
||||
builder.append("@")
|
||||
val nicknameStart = builder.length
|
||||
builder.append(truncateNickname(baseName))
|
||||
val nicknameEnd = builder.length
|
||||
@ -306,112 +303,23 @@ fun formatMessageHeaderAnnotatedString(
|
||||
}
|
||||
builder.pop()
|
||||
|
||||
// Hashtag suffix
|
||||
if (suffix.isNotEmpty()) {
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = baseColor.copy(alpha = 0.6f),
|
||||
fontSize = BASE_FONT_SIZE.sp,
|
||||
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Medium
|
||||
))
|
||||
builder.pushStyle(
|
||||
SpanStyle(
|
||||
color = baseColor.copy(alpha = SUFFIX_ALPHA),
|
||||
fontSize = ChatVisualTokens.SenderFontSize,
|
||||
fontWeight = FontWeight.Normal,
|
||||
)
|
||||
)
|
||||
builder.append(suffix)
|
||||
builder.pop()
|
||||
}
|
||||
|
||||
// Sender suffix ">"
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = baseColor,
|
||||
fontSize = BASE_FONT_SIZE.sp,
|
||||
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Medium
|
||||
))
|
||||
builder.append(">")
|
||||
builder.pop()
|
||||
|
||||
// Timestamp and optional PoW bits, matching normal message appearance
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = Color.Gray.copy(alpha = 0.7f),
|
||||
fontSize = (BASE_FONT_SIZE - 4).sp
|
||||
))
|
||||
builder.append(" [${timeFormatter.format(message.timestamp)}]")
|
||||
message.powDifficulty?.let { bits ->
|
||||
if (bits > 0) builder.append(" ⛨${bits}b")
|
||||
}
|
||||
builder.pop()
|
||||
} else {
|
||||
// System message header (should rarely apply to voice)
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = Color.Gray,
|
||||
fontSize = (BASE_FONT_SIZE - 2).sp,
|
||||
fontStyle = androidx.compose.ui.text.font.FontStyle.Italic
|
||||
))
|
||||
builder.append("* ${message.content} *")
|
||||
builder.pop()
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = Color.Gray.copy(alpha = 0.5f),
|
||||
fontSize = (BASE_FONT_SIZE - 4).sp
|
||||
))
|
||||
builder.append(" [${timeFormatter.format(message.timestamp)}]")
|
||||
builder.pop()
|
||||
}
|
||||
|
||||
appendMutedTimestamp(builder, message, contentColor, timeFormatter)
|
||||
return builder.toAnnotatedString()
|
||||
}
|
||||
|
||||
/**
|
||||
* iOS-style peer color assignment using djb2 hash algorithm
|
||||
* Avoids orange (~30°) reserved for self messages
|
||||
*/
|
||||
fun getPeerColor(message: BitchatMessage, isDark: Boolean): Color {
|
||||
// Create seed from peer identifier (prioritizing stable keys)
|
||||
val seed = when {
|
||||
message.senderPeerID?.startsWith("nostr:") == true || message.senderPeerID?.startsWith("nostr_") == true -> {
|
||||
// For Nostr peers, use the full key if available, otherwise the peer ID
|
||||
"nostr:${message.senderPeerID.lowercase()}"
|
||||
}
|
||||
message.senderPeerID?.length == 16 -> {
|
||||
// For ephemeral peer IDs, try to get stable Noise key, fallback to peer ID
|
||||
"noise:${message.senderPeerID.lowercase()}"
|
||||
}
|
||||
message.senderPeerID?.length == 64 -> {
|
||||
// This is already a stable Noise key
|
||||
"noise:${message.senderPeerID.lowercase()}"
|
||||
}
|
||||
else -> {
|
||||
// Fallback to sender name
|
||||
message.sender.lowercase()
|
||||
}
|
||||
}
|
||||
|
||||
return colorForPeerSeed(seed, isDark)
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate consistent peer color using djb2 hash (matches iOS algorithm exactly)
|
||||
*/
|
||||
fun colorForPeerSeed(seed: String, isDark: Boolean): Color {
|
||||
// djb2 hash algorithm (matches iOS implementation)
|
||||
var hash = 5381UL
|
||||
for (byte in seed.toByteArray()) {
|
||||
hash = ((hash shl 5) + hash) + byte.toUByte().toULong()
|
||||
}
|
||||
|
||||
var hue = (hash % 360UL).toDouble() / 360.0
|
||||
|
||||
// Avoid orange (~30°) reserved for self (matches iOS logic)
|
||||
val orange = 30.0 / 360.0
|
||||
if (kotlin.math.abs(hue - orange) < 0.05) {
|
||||
hue = (hue + 0.12) % 1.0
|
||||
}
|
||||
|
||||
val saturation = if (isDark) 0.50 else 0.70
|
||||
val brightness = if (isDark) 0.85 else 0.35
|
||||
|
||||
return Color.hsv(
|
||||
hue = (hue * 360).toFloat(),
|
||||
saturation = saturation.toFloat(),
|
||||
value = brightness.toFloat()
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a name into base and a '#abcd' suffix if present (matches iOS splitSuffix exactly)
|
||||
*/
|
||||
@ -430,23 +338,106 @@ fun splitSuffix(name: String): Pair<String, String> {
|
||||
}
|
||||
|
||||
/**
|
||||
* iOS-style content formatting with proper hashtag and mention handling
|
||||
* Build a case-insensitive mention-token lookup from canonical peer identities.
|
||||
*
|
||||
* Suffixed names such as `alice#04af` resolve exactly. Their unsuffixed base is only retained when
|
||||
* it identifies one peer; ambiguous bases are deliberately omitted rather than coloring a mention
|
||||
* as the wrong person.
|
||||
*/
|
||||
internal fun buildMentionPeerIdentityMap(
|
||||
messages: List<BitchatMessage>,
|
||||
knownPeers: List<Pair<String, PeerIdentity>> = emptyList(),
|
||||
): Map<String, PeerIdentity> {
|
||||
val candidates = linkedMapOf<String, MutableSet<PeerIdentity>>()
|
||||
|
||||
fun add(displayName: String, identity: PeerIdentity) {
|
||||
val normalizedName = displayName.trim().removePrefix("@")
|
||||
if (normalizedName.isEmpty()) return
|
||||
|
||||
val (baseName, suffix) = splitSuffix(normalizedName)
|
||||
val exactKey = normalizedName.lowercase(Locale.ROOT)
|
||||
candidates.getOrPut(exactKey) { linkedSetOf() }.add(identity)
|
||||
|
||||
if (suffix.isNotEmpty()) {
|
||||
val baseKey = baseName.lowercase(Locale.ROOT)
|
||||
candidates.getOrPut(baseKey) { linkedSetOf() }.add(identity)
|
||||
}
|
||||
}
|
||||
|
||||
messages
|
||||
.asSequence()
|
||||
.filterNot { it.sender == "system" }
|
||||
.forEach { add(it.sender, peerIdentityForMessage(it)) }
|
||||
knownPeers.forEach { (displayName, identity) -> add(displayName, identity) }
|
||||
|
||||
return candidates.mapNotNull { (token, identities) ->
|
||||
identities.singleOrNull()?.let { token to it }
|
||||
}.toMap()
|
||||
}
|
||||
|
||||
internal fun resolveMentionPeerIdentity(
|
||||
mention: String,
|
||||
mentionPeerIdentities: Map<String, PeerIdentity>,
|
||||
): PeerIdentity? {
|
||||
val mentionWithoutAt = mention.trim().removePrefix("@")
|
||||
val baseName = splitSuffix(mentionWithoutAt).first
|
||||
return mentionPeerIdentities[mentionWithoutAt.lowercase(Locale.ROOT)]
|
||||
?: mentionPeerIdentities[baseName.lowercase(Locale.ROOT)]
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the deterministic color for a mention on every surface that displays one.
|
||||
*
|
||||
* Exact suffixed tokens win; an unsuffixed nickname is only present in the identity map when it is
|
||||
* unambiguous. The nickname fallback preserves the legacy behavior for peers with no stable ID.
|
||||
*/
|
||||
internal fun colorForMention(
|
||||
mention: String,
|
||||
mentionPeerIdentities: Map<String, PeerIdentity>,
|
||||
palette: BitchatPalette,
|
||||
): Color {
|
||||
val mentionWithoutAt = mention.trim().removePrefix("@")
|
||||
val identity = resolveMentionPeerIdentity(mentionWithoutAt, mentionPeerIdentities)
|
||||
?: PeerIdentity.nickname(mentionWithoutAt)
|
||||
return colorForPeer(identity, palette)
|
||||
}
|
||||
|
||||
/**
|
||||
* A bare `anon` label means the geohash heartbeat has not announced a username yet. The transport
|
||||
* may append a `#abcd` disambiguator, which does not turn it into an announced name. Names such as
|
||||
* `anon1234`, `anonymous`, and `anonracer` are real announced usernames.
|
||||
*/
|
||||
internal fun isUnannouncedNickname(displayName: String): Boolean {
|
||||
val base = splitSuffix(displayName.trim()).first
|
||||
return base.equals("anon", ignoreCase = true)
|
||||
}
|
||||
|
||||
/**
|
||||
* iOS-style content formatting with proper hashtag and mention handling.
|
||||
*
|
||||
* Redesign notes:
|
||||
* - Plain text renders in Material `onSurface`; colour is reserved for `@mentions`,
|
||||
* links and geohashes.
|
||||
* - Mentions get a tinted background chip so they read as a distinct token inside a sentence.
|
||||
* The chip is tinted by *the mentioned peer's* colour, not the sender's, so `@alice` looks
|
||||
* the same everywhere she is referenced.
|
||||
* - Neither "self" nor "you were mentioned" bolds the whole body any more. Bolding entire
|
||||
* paragraphs was the single biggest source of visual noise in the old layout; the mention
|
||||
* chip carries that emphasis instead.
|
||||
*/
|
||||
private fun appendIOSFormattedContent(
|
||||
builder: AnnotatedString.Builder,
|
||||
content: String,
|
||||
mentions: List<String>?,
|
||||
currentUserNickname: String,
|
||||
baseColor: Color,
|
||||
isSelf: Boolean,
|
||||
contentColor: Color = baseColor,
|
||||
palette: BitchatPalette,
|
||||
contentColor: Color,
|
||||
linkColor: Color,
|
||||
mentionPeerIdentities: Map<String, PeerIdentity>,
|
||||
) {
|
||||
// iOS-style patterns: allow optional '#abcd' suffix in mentions
|
||||
val hashtagPattern = "#([a-zA-Z0-9_]+)".toRegex()
|
||||
val mentionPattern = "@([\\p{L}0-9_]+(?:#[a-fA-F0-9]{4})?)".toRegex()
|
||||
|
||||
val hashtagMatches = hashtagPattern.findAll(content).toList()
|
||||
val mentionMatches = mentionPattern.findAll(content).toList()
|
||||
val mentionMatches = MENTION_TOKEN_REGEX.findAll(content).toList()
|
||||
|
||||
// Combine and sort matches, but exclude hashtags that overlap with mentions
|
||||
val mentionRanges = mentionMatches.map { it.range }
|
||||
@ -507,28 +498,28 @@ private fun appendIOSFormattedContent(
|
||||
}
|
||||
|
||||
allMatches.sortBy { it.first.first }
|
||||
|
||||
|
||||
val plainStyle = SpanStyle(
|
||||
color = contentColor,
|
||||
fontSize = BASE_FONT_SIZE.sp,
|
||||
fontWeight = FontWeight.Normal
|
||||
)
|
||||
val linkStyle = SpanStyle(
|
||||
color = linkColor,
|
||||
fontSize = BASE_FONT_SIZE.sp,
|
||||
fontWeight = FontWeight.Normal,
|
||||
textDecoration = TextDecoration.Underline
|
||||
)
|
||||
|
||||
var lastEnd = 0
|
||||
val isMentioned = mentions?.contains(currentUserNickname) == true
|
||||
|
||||
|
||||
for ((range, type) in allMatches) {
|
||||
// Add text before match
|
||||
if (lastEnd < range.first) {
|
||||
val beforeText = content.substring(lastEnd, range.first)
|
||||
if (beforeText.isNotEmpty()) {
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = contentColor,
|
||||
fontSize = BASE_FONT_SIZE.sp,
|
||||
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Normal
|
||||
))
|
||||
if (isMentioned) {
|
||||
// Make entire message bold if user is mentioned
|
||||
builder.pushStyle(SpanStyle(fontWeight = FontWeight.Bold))
|
||||
builder.append(beforeText)
|
||||
builder.pop()
|
||||
} else {
|
||||
builder.append(beforeText)
|
||||
}
|
||||
builder.pushStyle(plainStyle)
|
||||
builder.append(beforeText)
|
||||
builder.pop()
|
||||
}
|
||||
}
|
||||
@ -540,65 +531,58 @@ private fun appendIOSFormattedContent(
|
||||
// iOS-style mention with hashtag suffix support
|
||||
val mentionWithoutAt = matchText.removePrefix("@")
|
||||
val (mBase, mSuffix) = splitSuffix(mentionWithoutAt)
|
||||
|
||||
// Check if this mention targets current user
|
||||
|
||||
// Mentions targeting you are the one thing worth shouting about.
|
||||
val isMentionToMe = mBase == currentUserNickname
|
||||
val mentionColor = if (isMentionToMe) Color(0xFFFF9500) else baseColor
|
||||
|
||||
// "@" symbol
|
||||
val mentionColor = if (isMentionToMe) {
|
||||
palette.accentOrange
|
||||
} else {
|
||||
colorForMention(
|
||||
mention = mentionWithoutAt,
|
||||
mentionPeerIdentities = mentionPeerIdentities,
|
||||
palette = palette,
|
||||
)
|
||||
}
|
||||
val chipAlpha = if (isMentionToMe) MENTION_CHIP_ALPHA_SELF else MENTION_CHIP_ALPHA
|
||||
val mentionWeight = if (isMentionToMe) FontWeight.Bold else FontWeight.SemiBold
|
||||
|
||||
// A single outer span carrying the background makes the chip render as one
|
||||
// continuous rectangle. Pushing the background per-token would leave hairline
|
||||
// seams between "@", the name and the "#abcd" suffix.
|
||||
builder.pushStyle(SpanStyle(background = mentionColor.copy(alpha = chipAlpha)))
|
||||
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = mentionColor,
|
||||
fontSize = BASE_FONT_SIZE.sp,
|
||||
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.SemiBold
|
||||
fontWeight = mentionWeight
|
||||
))
|
||||
builder.append("@")
|
||||
builder.pop()
|
||||
|
||||
// Base name (truncate for rendering)
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = mentionColor,
|
||||
fontSize = BASE_FONT_SIZE.sp,
|
||||
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.SemiBold
|
||||
))
|
||||
builder.append(truncateNickname(mBase))
|
||||
builder.pop()
|
||||
|
||||
|
||||
// Hashtag suffix in lighter color
|
||||
if (mSuffix.isNotEmpty()) {
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = mentionColor.copy(alpha = 0.6f),
|
||||
color = mentionColor.copy(alpha = SUFFIX_ALPHA),
|
||||
fontSize = BASE_FONT_SIZE.sp,
|
||||
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.SemiBold
|
||||
fontWeight = mentionWeight
|
||||
))
|
||||
builder.append(mSuffix)
|
||||
builder.pop()
|
||||
}
|
||||
|
||||
builder.pop() // background chip
|
||||
}
|
||||
"hashtag" -> {
|
||||
// Render general hashtags like normal content
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = contentColor,
|
||||
fontSize = BASE_FONT_SIZE.sp,
|
||||
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Normal
|
||||
))
|
||||
if (isMentioned) {
|
||||
builder.pushStyle(SpanStyle(fontWeight = FontWeight.Bold))
|
||||
builder.append(matchText)
|
||||
builder.pop()
|
||||
} else {
|
||||
builder.append(matchText)
|
||||
}
|
||||
builder.pushStyle(plainStyle)
|
||||
builder.append(matchText)
|
||||
builder.pop()
|
||||
}
|
||||
else -> {
|
||||
if (type == "geohash") {
|
||||
// Style geohash in blue, underlined, and add click annotation
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = Color(0xFF007AFF),
|
||||
fontSize = BASE_FONT_SIZE.sp,
|
||||
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.SemiBold,
|
||||
textDecoration = TextDecoration.Underline
|
||||
))
|
||||
// Style geohash as a link and add click annotation
|
||||
builder.pushStyle(linkStyle)
|
||||
val start = builder.length
|
||||
builder.append(matchText)
|
||||
val end = builder.length
|
||||
@ -611,13 +595,8 @@ private fun appendIOSFormattedContent(
|
||||
)
|
||||
builder.pop()
|
||||
} else if (type == "url") {
|
||||
// Style URL in blue, underlined, and add click annotation with the raw text
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = Color(0xFF007AFF),
|
||||
fontSize = BASE_FONT_SIZE.sp,
|
||||
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.SemiBold,
|
||||
textDecoration = TextDecoration.Underline
|
||||
))
|
||||
// Style URL as a link and add click annotation with the raw text
|
||||
builder.pushStyle(linkStyle)
|
||||
val start = builder.length
|
||||
builder.append(matchText)
|
||||
val end = builder.length
|
||||
@ -630,11 +609,7 @@ private fun appendIOSFormattedContent(
|
||||
builder.pop()
|
||||
} else {
|
||||
// Fallback: treat as normal text
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = contentColor,
|
||||
fontSize = BASE_FONT_SIZE.sp,
|
||||
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Normal
|
||||
))
|
||||
builder.pushStyle(plainStyle)
|
||||
builder.append(matchText)
|
||||
builder.pop()
|
||||
}
|
||||
@ -647,18 +622,8 @@ private fun appendIOSFormattedContent(
|
||||
// Add remaining text
|
||||
if (lastEnd < content.length) {
|
||||
val remainingText = content.substring(lastEnd)
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = contentColor,
|
||||
fontSize = BASE_FONT_SIZE.sp,
|
||||
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Normal
|
||||
))
|
||||
if (isMentioned) {
|
||||
builder.pushStyle(SpanStyle(fontWeight = FontWeight.Bold))
|
||||
builder.append(remainingText)
|
||||
builder.pop()
|
||||
} else {
|
||||
builder.append(remainingText)
|
||||
}
|
||||
builder.pushStyle(plainStyle)
|
||||
builder.append(remainingText)
|
||||
builder.pop()
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,11 +6,12 @@ import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.bitchat.android.ui.theme.BitchatFontFamily
|
||||
import com.bitchat.android.ui.theme.BASE_FONT_SIZE
|
||||
import com.bitchat.android.ui.theme.LocalBitchatPalette
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.bitchat.android.R
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
@ -35,14 +36,13 @@ fun ChatUserSheet(
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
|
||||
// iOS system colors (matches LocationChannelsSheet exactly)
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
|
||||
val standardGreen = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D) // iOS green
|
||||
val standardBlue = Color(0xFF007AFF) // iOS blue
|
||||
val standardPurple = if (isDark) Color(0xFFBF5AF2) else Color(0xFFAF52DE) // iOS purple
|
||||
val standardRed = Color(0xFFFF3B30) // iOS red
|
||||
val standardGrey = if (isDark) Color(0xFF8E8E93) else Color(0xFF6D6D70) // iOS grey
|
||||
val palette = LocalBitchatPalette.current
|
||||
val standardGreen = colorScheme.primary
|
||||
val standardBlue = colorScheme.secondary
|
||||
val standardPurple = palette.accentPurple
|
||||
val standardRed = colorScheme.error
|
||||
val standardGrey = colorScheme.onSurfaceVariant
|
||||
|
||||
if (isPresented) {
|
||||
BitchatBottomSheet(
|
||||
@ -59,7 +59,7 @@ fun ChatUserSheet(
|
||||
Text(
|
||||
text = stringResource(R.string.at_nickname, targetNickname),
|
||||
fontSize = 18.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
@ -67,7 +67,7 @@ fun ChatUserSheet(
|
||||
Text(
|
||||
text = if (selectedMessage != null) stringResource(R.string.choose_action_message_or_user) else stringResource(R.string.choose_action_user),
|
||||
fontSize = 12.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
|
||||
)
|
||||
|
||||
@ -183,7 +183,7 @@ fun ChatUserSheet(
|
||||
Text(
|
||||
text = stringResource(R.string.cancel_lower),
|
||||
fontSize = BASE_FONT_SIZE.sp,
|
||||
fontFamily = FontFamily.Monospace
|
||||
fontFamily = BitchatFontFamily
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -214,7 +214,7 @@ private fun UserActionRow(
|
||||
Text(
|
||||
text = title,
|
||||
fontSize = BASE_FONT_SIZE.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = titleColor
|
||||
)
|
||||
@ -222,7 +222,7 @@ private fun UserActionRow(
|
||||
Text(
|
||||
text = subtitle,
|
||||
fontSize = 12.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
|
||||
)
|
||||
}
|
||||
|
||||
@ -442,42 +442,9 @@ class ChatViewModel(
|
||||
|
||||
/**
|
||||
* Ensure Nostr DM subscription for a geohash conversation key if known
|
||||
* Minimal-change approach: reflectively access GeohashViewModel internals to reuse pipeline
|
||||
*/
|
||||
private fun ensureGeohashDMSubscriptionIfNeeded(convKey: String) {
|
||||
try {
|
||||
val repoField = GeohashViewModel::class.java.getDeclaredField("repo")
|
||||
repoField.isAccessible = true
|
||||
val repo = repoField.get(geohashViewModel) as com.bitchat.android.nostr.GeohashRepository
|
||||
val gh = repo.getConversationGeohash(convKey)
|
||||
if (!gh.isNullOrEmpty()) {
|
||||
val subMgrField = GeohashViewModel::class.java.getDeclaredField("subscriptionManager")
|
||||
subMgrField.isAccessible = true
|
||||
val subMgr = subMgrField.get(geohashViewModel) as com.bitchat.android.nostr.NostrSubscriptionManager
|
||||
val identity = com.bitchat.android.nostr.NostrIdentityBridge.deriveIdentity(gh, getApplication())
|
||||
val subId = "geo-dm-$gh"
|
||||
val currentDmSubField = GeohashViewModel::class.java.getDeclaredField("currentDmSubId")
|
||||
currentDmSubField.isAccessible = true
|
||||
val currentId = currentDmSubField.get(geohashViewModel) as String?
|
||||
if (currentId != subId) {
|
||||
(currentId)?.let { subMgr.unsubscribe(it) }
|
||||
currentDmSubField.set(geohashViewModel, subId)
|
||||
subMgr.subscribeGiftWraps(
|
||||
pubkey = identity.publicKeyHex,
|
||||
sinceMs = System.currentTimeMillis() - 172800000L,
|
||||
id = subId,
|
||||
handler = { event ->
|
||||
val dmHandlerField = GeohashViewModel::class.java.getDeclaredField("dmHandler")
|
||||
dmHandlerField.isAccessible = true
|
||||
val dmHandler = dmHandlerField.get(geohashViewModel) as com.bitchat.android.nostr.NostrDirectMessageHandler
|
||||
dmHandler.onGiftWrap(event, gh, identity)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "ensureGeohashDMSubscriptionIfNeeded failed: ${e.message}")
|
||||
}
|
||||
geohashViewModel.ensureGeohashDMSubscriptionForConversation(convKey)
|
||||
}
|
||||
|
||||
// MARK: - Channel Management (delegated)
|
||||
@ -1259,6 +1226,11 @@ class ChatViewModel(
|
||||
|
||||
fun panicClearAllData() {
|
||||
Log.w(TAG, "🚨 PANIC MODE ACTIVATED - Clearing all sensitive data")
|
||||
try {
|
||||
com.bitchat.android.geohash.LocationChannelManager
|
||||
.getInstance(getApplication())
|
||||
.disableLocationServices()
|
||||
} catch (_: Exception) { }
|
||||
|
||||
// A pending one-shot downgrade confirmation must not survive panic or
|
||||
// become actionable against the fresh post-wipe identity.
|
||||
@ -1414,8 +1386,14 @@ class ChatViewModel(
|
||||
/**
|
||||
* Begin sampling multiple geohashes for participant activity
|
||||
*/
|
||||
fun beginGeohashSampling(geohashes: List<String>) {
|
||||
geohashViewModel.beginGeohashSampling(geohashes)
|
||||
fun beginGeohashSampling(
|
||||
liveLocationGeohashes: Collection<String>,
|
||||
userSelectedGeohashes: Collection<String>,
|
||||
) {
|
||||
geohashViewModel.beginGeohashSampling(
|
||||
liveLocationGeohashes = liveLocationGeohashes,
|
||||
userSelectedGeohashes = userSelectedGeohashes
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@ -1506,22 +1484,17 @@ class ChatViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - iOS-Compatible Color System
|
||||
// MARK: - Canonical peer identities
|
||||
|
||||
/**
|
||||
* Get consistent color for a mesh peer by ID (iOS-compatible)
|
||||
* Return the stable identity used by every UI surface to color a mesh peer.
|
||||
*/
|
||||
fun colorForMeshPeer(peerID: String, isDark: Boolean): androidx.compose.ui.graphics.Color {
|
||||
// Try to get stable Noise key, fallback to peer ID
|
||||
val seed = "noise:${peerID.lowercase()}"
|
||||
return colorForPeerSeed(seed, isDark).copy()
|
||||
}
|
||||
fun peerIdentityForMeshPeer(peerID: String): PeerIdentity = PeerIdentity.mesh(peerID)
|
||||
|
||||
/**
|
||||
* Get consistent color for a Nostr pubkey (iOS-compatible)
|
||||
* Return the stable identity used by every UI surface to color a Nostr peer.
|
||||
*/
|
||||
fun colorForNostrPubkey(pubkeyHex: String, isDark: Boolean): androidx.compose.ui.graphics.Color {
|
||||
return geohashViewModel.colorForNostrPubkey(pubkeyHex, isDark)
|
||||
}
|
||||
fun peerIdentityForNostrPubkey(pubkeyHex: String): PeerIdentity =
|
||||
geohashViewModel.peerIdentityForNostrPubkey(pubkeyHex)
|
||||
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@ package com.bitchat.android.ui
|
||||
import com.bitchat.android.mesh.MeshService
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Handles processing of IRC-style commands
|
||||
@ -448,18 +449,28 @@ class CommandProcessor(
|
||||
is com.bitchat.android.geohash.ChannelID.Mesh,
|
||||
null -> {
|
||||
// Mesh channel: use Bluetooth mesh peer nicknames
|
||||
meshService.getPeerNicknames().values.filter { it != meshService.getPeerNicknames()[meshService.myPeerID] }
|
||||
val peerNicknames = meshService.getPeerNicknames()
|
||||
peerNicknames.values.filter { it != peerNicknames[meshService.myPeerID] }
|
||||
}
|
||||
|
||||
is com.bitchat.android.geohash.ChannelID.Location -> {
|
||||
// Location channel: use geohash participants with collision-resistant suffixes
|
||||
val geohashPeople = viewModel.geohashPeople.value ?: emptyList()
|
||||
val geohashPeople = viewModel.geohashPeople.value
|
||||
val currentNickname = state.getNicknameValue()
|
||||
val duplicateNames = duplicateGeohashBaseNames(geohashPeople)
|
||||
|
||||
geohashPeople.mapNotNull { person ->
|
||||
val displayName = person.displayName
|
||||
// Exclude self from suggestions
|
||||
if (displayName.startsWith("${currentNickname}#")) {
|
||||
val baseName = splitSuffix(person.displayName).first
|
||||
val hasNicknameCollision =
|
||||
baseName.lowercase(Locale.ROOT) in duplicateNames
|
||||
val displayName = disambiguatedGeohashDisplayName(person, duplicateNames)
|
||||
// A unique local nickname can be excluded directly. If it collides, the
|
||||
// nickname alone cannot identify which row is self, so keep the suffixed
|
||||
// rows rather than accidentally hiding the other user.
|
||||
if (
|
||||
!hasNicknameCollision &&
|
||||
baseName.equals(currentNickname, ignoreCase = true)
|
||||
) {
|
||||
null
|
||||
} else {
|
||||
displayName
|
||||
@ -469,13 +480,11 @@ class CommandProcessor(
|
||||
}
|
||||
} else {
|
||||
// Fallback to mesh peers if no viewModel available
|
||||
meshService.getPeerNicknames().values.filter { it != meshService.getPeerNicknames()[meshService.myPeerID] }
|
||||
val peerNicknames = meshService.getPeerNicknames()
|
||||
peerNicknames.values.filter { it != peerNicknames[meshService.myPeerID] }
|
||||
}
|
||||
|
||||
// Filter nicknames based on the text after @
|
||||
val filteredNicknames = peerCandidates.filter { nickname ->
|
||||
nickname.startsWith(textAfterAt, ignoreCase = true)
|
||||
}.sorted()
|
||||
val filteredNicknames = filterMentionCandidates(peerCandidates, textAfterAt)
|
||||
|
||||
if (filteredNicknames.isNotEmpty()) {
|
||||
state.setMentionSuggestions(filteredNicknames)
|
||||
@ -533,3 +542,22 @@ class CommandProcessor(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep mention autocomplete useful in crowded channels: a bare `anon` identity has not announced
|
||||
* a username and is not actionable. Names such as `anon1234` are announced usernames and remain
|
||||
* valid mention targets.
|
||||
*/
|
||||
internal fun filterMentionCandidates(
|
||||
candidates: List<String>,
|
||||
query: String
|
||||
): List<String> {
|
||||
return candidates.asSequence()
|
||||
.map(String::trim)
|
||||
.filter(String::isNotEmpty)
|
||||
.filterNot(::isUnannouncedNickname)
|
||||
.filter { nickname -> nickname.startsWith(query, ignoreCase = true) }
|
||||
.distinctBy { nickname -> nickname.lowercase(Locale.ROOT) }
|
||||
.sortedWith(String.CASE_INSENSITIVE_ORDER)
|
||||
.toList()
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@ package com.bitchat.android.ui
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.util.Log
|
||||
import com.bitchat.android.geohash.DEFAULT_LIVE_LOCATION_ENABLED
|
||||
import com.google.gson.Gson
|
||||
import kotlin.random.Random
|
||||
|
||||
@ -54,7 +55,7 @@ class DataManager(private val context: Context) {
|
||||
|
||||
fun saveLastGeohashChannel(channelData: String) {
|
||||
prefs.edit().putString("last_geohash_channel", channelData).apply()
|
||||
Log.d(TAG, "Saved last geohash channel: $channelData")
|
||||
Log.d(TAG, "Saved last geohash channel")
|
||||
}
|
||||
|
||||
fun clearLastGeohashChannel() {
|
||||
@ -65,12 +66,12 @@ class DataManager(private val context: Context) {
|
||||
// MARK: - Location Services State
|
||||
|
||||
fun saveLocationServicesEnabled(enabled: Boolean) {
|
||||
prefs.edit().putBoolean("location_services_enabled", enabled).apply()
|
||||
prefs.edit().putBoolean("location_services_enabled", enabled).commit()
|
||||
Log.d(TAG, "Saved location services enabled state: $enabled")
|
||||
}
|
||||
|
||||
fun isLocationServicesEnabled(): Boolean {
|
||||
return prefs.getBoolean("location_services_enabled", true) // Default to enabled
|
||||
return prefs.getBoolean("location_services_enabled", DEFAULT_LIVE_LOCATION_ENABLED)
|
||||
}
|
||||
|
||||
// MARK: - Channel Data Management
|
||||
|
||||
@ -1,39 +1,34 @@
|
||||
package com.bitchat.android.ui
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material.icons.outlined.Explore
|
||||
import androidx.compose.material.icons.outlined.LocationOn
|
||||
import androidx.compose.material.icons.filled.Email
|
||||
import android.util.Log
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.bitchat.android.ui.theme.BASE_FONT_SIZE
|
||||
import java.util.*
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.bitchat.android.ui.theme.BitchatFontFamily
|
||||
import com.bitchat.android.ui.theme.colorForPeer
|
||||
import com.bitchat.android.R
|
||||
import com.bitchat.android.ui.theme.LocalBitchatPalette
|
||||
import java.util.*
|
||||
|
||||
/**
|
||||
* GeohashPeopleList - iOS-compatible component for displaying geohash participants
|
||||
* Shows peers discovered through Nostr ephemeral events instead of Bluetooth peers
|
||||
* Geohash people list — card groups matching location / settings sheet rows.
|
||||
*/
|
||||
|
||||
/**
|
||||
* GeoPerson data class - matches iOS GeoPerson structure exactly
|
||||
*/
|
||||
data class GeoPerson(
|
||||
val id: String, // pubkey hex (lowercased) - matches iOS
|
||||
val displayName: String, // nickname with #suffix - matches iOS
|
||||
val displayName: String, // nickname with #suffix - matches iOS
|
||||
val lastSeen: Date // activity timestamp - matches iOS
|
||||
)
|
||||
|
||||
@ -43,116 +38,227 @@ fun GeohashPeopleList(
|
||||
onTapPerson: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
|
||||
// Observe geohash people from ChatViewModel
|
||||
val geohashPeople by viewModel.geohashPeople.collectAsStateWithLifecycle()
|
||||
val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
|
||||
val isTeleported by viewModel.isTeleported.collectAsStateWithLifecycle()
|
||||
val teleportedGeo by viewModel.teleportedGeo.collectAsStateWithLifecycle()
|
||||
val nickname by viewModel.nickname.collectAsStateWithLifecycle()
|
||||
val unreadPrivateMessages by viewModel.unreadPrivateMessages.collectAsStateWithLifecycle()
|
||||
|
||||
Column {
|
||||
// Header matching iOS style
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.LocationOn,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(12.dp),
|
||||
tint = colorScheme.onSurface.copy(alpha = 0.6f)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.geohash_people_header),
|
||||
style = MaterialTheme.typography.labelSmall.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Bold
|
||||
),
|
||||
color = colorScheme.onSurface.copy(alpha = 0.6f)
|
||||
)
|
||||
}
|
||||
|
||||
if (geohashPeople.isEmpty()) {
|
||||
// Empty state - matches iOS "nobody around..."
|
||||
Text(
|
||||
text = stringResource(R.string.nobody_around),
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = BASE_FONT_SIZE.sp
|
||||
),
|
||||
color = colorScheme.onSurface.copy(alpha = 0.5f),
|
||||
modifier = Modifier.padding(horizontal = 24.dp, vertical = 12.dp)
|
||||
)
|
||||
} else {
|
||||
// Get current geohash identity for "me" detection
|
||||
val myHex = remember(selectedLocationChannel) {
|
||||
when (val channel = selectedLocationChannel) {
|
||||
is com.bitchat.android.geohash.ChannelID.Location -> {
|
||||
try {
|
||||
val identity = com.bitchat.android.nostr.NostrIdentityBridge.deriveIdentity(
|
||||
forGeohash = channel.channel.geohash,
|
||||
context = viewModel.getApplication()
|
||||
)
|
||||
identity.publicKeyHex.lowercase()
|
||||
} catch (e: Exception) {
|
||||
Log.e("GeohashPeopleList", "Failed to derive identity: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
// Sort people: me first, then by lastSeen (matches iOS exactly)
|
||||
val orderedPeople = remember(geohashPeople, myHex) {
|
||||
geohashPeople.sortedWith { a, b ->
|
||||
when {
|
||||
myHex != null && a.id == myHex && b.id != myHex -> -1
|
||||
myHex != null && b.id == myHex && a.id != myHex -> 1
|
||||
else -> b.lastSeen.compareTo(a.lastSeen) // Most recent first
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Compute base name collisions to decide whether to show hash suffix
|
||||
val baseNameCounts = remember(geohashPeople) {
|
||||
val counts = mutableMapOf<String, Int>()
|
||||
geohashPeople.forEach { person ->
|
||||
val (b, _) = com.bitchat.android.ui.splitSuffix(person.displayName)
|
||||
counts[b] = (counts[b] ?: 0) + 1
|
||||
val palette = LocalBitchatPalette.current
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val myHex = remember(selectedLocationChannel) {
|
||||
when (val channel = selectedLocationChannel) {
|
||||
is com.bitchat.android.geohash.ChannelID.Location -> {
|
||||
try {
|
||||
val identity = com.bitchat.android.nostr.NostrIdentityBridge.deriveIdentity(
|
||||
forGeohash = channel.channel.geohash,
|
||||
context = viewModel.getApplication()
|
||||
)
|
||||
identity.publicKeyHex.lowercase(Locale.ROOT)
|
||||
} catch (e: Exception) {
|
||||
Log.e("GeohashPeopleList", "Failed to derive identity: ${e.message}")
|
||||
null
|
||||
}
|
||||
counts
|
||||
}
|
||||
|
||||
val firstID = orderedPeople.firstOrNull()?.id
|
||||
|
||||
orderedPeople.forEach { person ->
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
val peopleIncludingSelf = remember(geohashPeople, myHex, nickname) {
|
||||
if (myHex != null && geohashPeople.none { it.id.equals(myHex, ignoreCase = true) }) {
|
||||
listOf(
|
||||
GeoPerson(
|
||||
id = myHex,
|
||||
displayName = nickname.ifBlank { "anon" },
|
||||
lastSeen = Date(0)
|
||||
)
|
||||
) + geohashPeople
|
||||
} else {
|
||||
geohashPeople
|
||||
}
|
||||
}
|
||||
val sections = remember(peopleIncludingSelf, myHex, isTeleported, teleportedGeo) {
|
||||
sectionGeohashPeople(
|
||||
people = peopleIncludingSelf,
|
||||
myId = myHex,
|
||||
selfIsTeleported = isTeleported,
|
||||
teleportedIds = teleportedGeo
|
||||
)
|
||||
}
|
||||
val displayedPeople = remember(sections) {
|
||||
sections.onLocation + sections.teleportedIn
|
||||
}
|
||||
val teleportedPersonIds = remember(sections.teleportedIn) {
|
||||
sections.teleportedIn.mapTo(mutableSetOf()) { it.id.lowercase(Locale.ROOT) }
|
||||
}
|
||||
val duplicateBaseNames = remember(displayedPeople) {
|
||||
duplicateGeohashBaseNames(displayedPeople)
|
||||
}
|
||||
|
||||
Column(modifier = modifier) {
|
||||
SheetIconSectionHeader(
|
||||
iconRes = R.drawable.ic_spec_people,
|
||||
title = stringResource(R.string.people_count_title, displayedPeople.size)
|
||||
)
|
||||
|
||||
if (displayedPeople.isEmpty()) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = AboutHorizontalPadding)
|
||||
.padding(top = 10.dp),
|
||||
color = colorScheme.surface,
|
||||
shape = AboutCardShape
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.nobody_around),
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 12.sp,
|
||||
color = palette.textTertiary,
|
||||
modifier = Modifier.padding(
|
||||
horizontal = SheetRowHorizontal,
|
||||
vertical = SheetRowVertical
|
||||
)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
@Composable
|
||||
fun personRow(person: GeoPerson) {
|
||||
val isMe = myHex != null && person.id.equals(myHex, ignoreCase = true)
|
||||
val personIsTeleported = if (isMe) {
|
||||
isTeleported
|
||||
} else {
|
||||
person.id.lowercase(Locale.ROOT) in teleportedPersonIds
|
||||
}
|
||||
GeohashPersonItem(
|
||||
person = person,
|
||||
isFirst = person.id == firstID,
|
||||
isMe = myHex != null && person.id == myHex,
|
||||
isMe = isMe,
|
||||
hasUnreadDM = unreadPrivateMessages.contains("nostr_${person.id.take(16)}"),
|
||||
isTeleported = person.id != myHex && viewModel.isPersonTeleported(person.id),
|
||||
isMyTeleported = person.id == myHex && isTeleported,
|
||||
nickname = nickname,
|
||||
colorScheme = colorScheme,
|
||||
isTeleported = personIsTeleported,
|
||||
viewModel = viewModel,
|
||||
showHashSuffix = (baseNameCounts[com.bitchat.android.ui.splitSuffix(person.displayName).first] ?: 0) > 1,
|
||||
showHashSuffix = splitSuffix(person.displayName)
|
||||
.first
|
||||
.lowercase(Locale.ROOT) in duplicateBaseNames,
|
||||
onTap = {
|
||||
if (person.id != myHex) {
|
||||
// TODO: Re-enable when NIP-17 geohash DM issues are fixed
|
||||
// Start geohash DM (iOS-compatible)
|
||||
if (!isMe) {
|
||||
viewModel.startGeohashDM(person.id)
|
||||
onTapPerson()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (sections.onLocation.isNotEmpty()) {
|
||||
AboutSectionLabel(text = stringResource(R.string.section_on_location))
|
||||
PeopleCard(
|
||||
people = sections.onLocation,
|
||||
row = { personRow(it) }
|
||||
)
|
||||
}
|
||||
|
||||
if (sections.teleportedIn.isNotEmpty()) {
|
||||
AboutSectionLabel(text = stringResource(R.string.section_teleported_in))
|
||||
PeopleCard(
|
||||
people = sections.teleportedIn,
|
||||
row = { personRow(it) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal data class GeohashPeopleSections(
|
||||
val onLocation: List<GeoPerson>,
|
||||
val teleportedIn: List<GeoPerson>
|
||||
)
|
||||
|
||||
/**
|
||||
* Names that require a short identity suffix, calculated across both people sections.
|
||||
*
|
||||
* Matching is case-insensitive to mirror geohash chat's nickname collision handling.
|
||||
*/
|
||||
internal fun duplicateGeohashBaseNames(people: List<GeoPerson>): Set<String> =
|
||||
people
|
||||
.groupingBy { splitSuffix(it.displayName).first.lowercase(Locale.ROOT) }
|
||||
.eachCount()
|
||||
.filterValues { it > 1 }
|
||||
.keys
|
||||
|
||||
/**
|
||||
* The same `#abcd` disambiguator used by geohash chat.
|
||||
*
|
||||
* Presence rows normally carry only a base nickname, so derive the suffix from the full Nostr
|
||||
* public key when a collision exists. Preserve an already-announced suffix for compatibility.
|
||||
*/
|
||||
internal fun geohashIdentitySuffix(person: GeoPerson, showHashSuffix: Boolean): String {
|
||||
if (!showHashSuffix) return ""
|
||||
val announcedSuffix = splitSuffix(person.displayName).second
|
||||
return announcedSuffix.ifEmpty { "#${person.id.takeLast(4)}" }
|
||||
}
|
||||
|
||||
internal fun disambiguatedGeohashDisplayName(
|
||||
person: GeoPerson,
|
||||
duplicateBaseNames: Set<String>,
|
||||
): String {
|
||||
val baseName = splitSuffix(person.displayName).first
|
||||
val showSuffix = baseName.lowercase(Locale.ROOT) in duplicateBaseNames
|
||||
return baseName + geohashIdentitySuffix(person, showSuffix)
|
||||
}
|
||||
|
||||
/**
|
||||
* Split announced identities by how they entered this geohash. Bare `anon` heartbeat identities
|
||||
* are omitted, while announced names such as `anon1234` remain ordinary participants. Self is
|
||||
* retained even before a nickname announcement and is always first in the matching section.
|
||||
*/
|
||||
internal fun sectionGeohashPeople(
|
||||
people: List<GeoPerson>,
|
||||
myId: String?,
|
||||
selfIsTeleported: Boolean,
|
||||
teleportedIds: Set<String>
|
||||
): GeohashPeopleSections {
|
||||
val normalizedMyId = myId?.lowercase(Locale.ROOT)
|
||||
val normalizedTeleportedIds = teleportedIds
|
||||
.mapTo(mutableSetOf()) { it.lowercase(Locale.ROOT) }
|
||||
fun isSelf(person: GeoPerson): Boolean =
|
||||
normalizedMyId != null && person.id.lowercase(Locale.ROOT) == normalizedMyId
|
||||
fun isTeleported(person: GeoPerson): Boolean =
|
||||
if (isSelf(person)) selfIsTeleported
|
||||
else person.id.lowercase(Locale.ROOT) in normalizedTeleportedIds
|
||||
|
||||
val displayedPeople = people.filter { person ->
|
||||
isSelf(person) || !isUnannouncedNickname(person.displayName)
|
||||
}
|
||||
val ordered = displayedPeople.sortedWith(
|
||||
compareByDescending<GeoPerson>(::isSelf)
|
||||
.thenByDescending { it.lastSeen }
|
||||
)
|
||||
return GeohashPeopleSections(
|
||||
onLocation = ordered.filterNot(::isTeleported),
|
||||
teleportedIn = ordered.filter(::isTeleported)
|
||||
)
|
||||
}
|
||||
|
||||
/** One uncapped card of people. The enclosing sheet owns scrolling. */
|
||||
@Composable
|
||||
private fun PeopleCard(
|
||||
people: List<GeoPerson>,
|
||||
row: @Composable (GeoPerson) -> Unit
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = AboutHorizontalPadding)
|
||||
.padding(top = 10.dp),
|
||||
color = colorScheme.surface,
|
||||
shape = AboutCardShape
|
||||
) {
|
||||
AnimatedRowColumn(items = people, key = { it.id }) { index, person ->
|
||||
Column {
|
||||
if (index > 0) SheetCardDivider()
|
||||
row(person)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -160,113 +266,97 @@ fun GeohashPeopleList(
|
||||
@Composable
|
||||
private fun GeohashPersonItem(
|
||||
person: GeoPerson,
|
||||
isFirst: Boolean,
|
||||
isMe: Boolean,
|
||||
hasUnreadDM: Boolean,
|
||||
isTeleported: Boolean,
|
||||
isMyTeleported: Boolean,
|
||||
nickname: String,
|
||||
colorScheme: ColorScheme,
|
||||
viewModel: ChatViewModel,
|
||||
showHashSuffix: Boolean,
|
||||
onTap: () -> Unit
|
||||
) {
|
||||
val palette = LocalBitchatPalette.current
|
||||
|
||||
val statusIconRes =
|
||||
if (isTeleported) R.drawable.ic_spec_teleport
|
||||
else R.drawable.ic_spec_on_location_person
|
||||
|
||||
val (baseNameRaw, _) = splitSuffix(person.displayName)
|
||||
val baseName = truncateNickname(baseNameRaw)
|
||||
val suffix = geohashIdentitySuffix(person, showHashSuffix)
|
||||
val assignedColor = colorForPeer(
|
||||
viewModel.peerIdentityForNostrPubkey(person.id),
|
||||
palette
|
||||
)
|
||||
val baseColor = if (isMe) palette.accentOrange else assignedColor
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onTap() }
|
||||
.padding(horizontal = 24.dp, vertical = 4.dp)
|
||||
.padding(top = if (isFirst) 10.dp else 0.dp),
|
||||
// Exact height, not padding: a row that sizes to its content makes the card change
|
||||
// height whenever the list reorders.
|
||||
.height(SheetRowHeight)
|
||||
.clickable(onClick = onTap)
|
||||
.padding(horizontal = SheetRowHorizontal),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Icon logic matching iOS exactly
|
||||
if (hasUnreadDM) {
|
||||
// Unread DM indicator (orange envelope)
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Email,
|
||||
contentDescription = stringResource(R.string.cd_unread_message),
|
||||
modifier = Modifier.size(12.dp),
|
||||
tint = Color(0xFFFF9500) // iOS orange
|
||||
)
|
||||
} else {
|
||||
// Face icon with teleportation state
|
||||
val (iconName, iconColor) = when {
|
||||
isMe && isMyTeleported -> "face.dashed" to Color(0xFFFF9500) // Orange for teleported me
|
||||
isTeleported -> "face.dashed" to colorScheme.onSurface // Regular color for teleported others
|
||||
isMe -> "face.smiling" to Color(0xFFFF9500) // Orange for me
|
||||
else -> "face.smiling" to colorScheme.onSurface // Regular color for others
|
||||
Box(
|
||||
modifier = Modifier.size(SheetRowLeadingSlot),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (hasUnreadDM) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Email,
|
||||
contentDescription = stringResource(R.string.cd_unread_message),
|
||||
modifier = Modifier.size(22.dp),
|
||||
tint = palette.accentOrange
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
painter = painterResource(statusIconRes),
|
||||
contentDescription = if (isTeleported) {
|
||||
stringResource(R.string.cd_teleported)
|
||||
} else {
|
||||
stringResource(R.string.section_on_location)
|
||||
},
|
||||
modifier = Modifier.size(22.dp),
|
||||
tint = baseColor
|
||||
)
|
||||
}
|
||||
|
||||
// Use appropriate Material icon (closest match to iOS SF Symbols)
|
||||
val icon = when (iconName) {
|
||||
"face.dashed" -> Icons.Outlined.Explore
|
||||
else -> Icons.Outlined.LocationOn
|
||||
}
|
||||
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = if (isTeleported || isMyTeleported) "Teleported user" else "User",
|
||||
modifier = Modifier.size(12.dp),
|
||||
tint = iconColor.copy(alpha = if (iconName == "face.dashed") 0.6f else 1.0f) // Make dashed faces slightly transparent
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
|
||||
// Display name with suffix handling
|
||||
val (baseNameRaw, suffixRaw) = com.bitchat.android.ui.splitSuffix(person.displayName)
|
||||
val baseName = truncateNickname(baseNameRaw)
|
||||
val suffix = if (showHashSuffix) suffixRaw else ""
|
||||
|
||||
// Get consistent peer color (matches iOS color assignment exactly)
|
||||
val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
|
||||
val assignedColor = viewModel.colorForNostrPubkey(person.id, isDark)
|
||||
val baseColor = if (isMe) Color(0xFFFF9500) else assignedColor
|
||||
|
||||
|
||||
Spacer(modifier = Modifier.width(SheetRowLeadingGutter))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Base name with peer-specific color
|
||||
Text(
|
||||
text = baseName,
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = BASE_FONT_SIZE.sp,
|
||||
fontWeight = if (isMe) FontWeight.Bold else FontWeight.Normal
|
||||
),
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = if (isMe) FontWeight.Bold else FontWeight.Medium,
|
||||
color = baseColor,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
|
||||
// Suffix (collision-resistant #abcd) in lighter shade
|
||||
|
||||
if (suffix.isNotEmpty()) {
|
||||
Text(
|
||||
text = suffix,
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = BASE_FONT_SIZE.sp
|
||||
),
|
||||
color = baseColor.copy(alpha = 0.6f)
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Normal,
|
||||
color = baseColor.copy(alpha = SUFFIX_ALPHA)
|
||||
)
|
||||
}
|
||||
|
||||
// "You" indicator for current user
|
||||
|
||||
if (isMe) {
|
||||
Text(
|
||||
text = stringResource(R.string.you_suffix),
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = BASE_FONT_SIZE.sp
|
||||
),
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 14.sp,
|
||||
color = baseColor
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -1,5 +1,9 @@
|
||||
package com.bitchat.android.ui
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Remove
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
@ -15,10 +19,6 @@ import android.webkit.WebViewClient
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Remove
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
@ -30,14 +30,14 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.bitchat.android.ui.theme.BitchatTheme
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.bitchat.android.ui.theme.BitchatFontFamily
|
||||
import com.bitchat.android.R
|
||||
import androidx.core.view.updateLayoutParams
|
||||
import com.bitchat.android.geohash.Geohash
|
||||
@ -87,15 +87,13 @@ class GeohashPickerActivity : OrientationAwareActivity() {
|
||||
val initialPrecision = geohashToFocus?.length ?: 5
|
||||
|
||||
setContent {
|
||||
MaterialTheme {
|
||||
BitchatTheme {
|
||||
var currentGeohash by remember { mutableStateOf(geohashToFocus ?: "") }
|
||||
var precision by remember { mutableStateOf(initialPrecision.coerceIn(1, 12)) }
|
||||
var webViewRef by remember { mutableStateOf<WebView?>(null) }
|
||||
|
||||
// iOS system-like colors used across app
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
|
||||
val standardGreen = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D)
|
||||
val standardGreen = colorScheme.primary
|
||||
|
||||
Scaffold { padding ->
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
@ -186,7 +184,7 @@ class GeohashPickerActivity : OrientationAwareActivity() {
|
||||
text = stringResource(R.string.pan_zoom_instruction),
|
||||
fontSize = 12.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 14.dp, vertical = 10.dp)
|
||||
@ -211,7 +209,7 @@ class GeohashPickerActivity : OrientationAwareActivity() {
|
||||
Text(
|
||||
text = if (currentGeohash.isNotEmpty()) "#${currentGeohash}" else "select location",
|
||||
fontSize = BASE_FONT_SIZE.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier
|
||||
@ -277,7 +275,7 @@ class GeohashPickerActivity : OrientationAwareActivity() {
|
||||
Text(
|
||||
text = stringResource(R.string.select),
|
||||
fontSize = (BASE_FONT_SIZE - 2).sp,
|
||||
fontFamily = FontFamily.Monospace
|
||||
fontFamily = BitchatFontFamily
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,6 +8,8 @@ import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.ProcessLifecycleOwner
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.bitchat.android.geohash.GeohashNostrPrivacyPolicy
|
||||
import com.bitchat.android.geohash.LiveLocationPrivacyGate
|
||||
import com.bitchat.android.nostr.GeohashMessageHandler
|
||||
import com.bitchat.android.nostr.GeohashRepository
|
||||
import com.bitchat.android.nostr.NostrDirectMessageHandler
|
||||
@ -18,15 +20,18 @@ import com.bitchat.android.nostr.NostrSubscriptionManager
|
||||
import com.bitchat.android.nostr.PoWPreferenceManager
|
||||
import com.bitchat.android.nostr.GeohashAliasRegistry
|
||||
import com.bitchat.android.nostr.GeohashConversationRegistry
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.Date
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import java.security.SecureRandom
|
||||
import java.util.UUID
|
||||
import kotlin.random.asKotlinRandom
|
||||
|
||||
class GeohashViewModel(
|
||||
@ -69,10 +74,24 @@ class GeohashViewModel(
|
||||
// Presence heartbeat firehose (kind 20001). High-volume; paused while backgrounded.
|
||||
private var currentGeohashPresenceSubId: String? = null
|
||||
private var currentDmSubId: String? = null
|
||||
private var currentDmGeohash: String? = null
|
||||
private var geoTimer: Job? = null
|
||||
private var globalPresenceJob: Job? = null
|
||||
private var locationChannelManager: com.bitchat.android.geohash.LocationChannelManager? = null
|
||||
private val activeSamplingGeohashes = mutableSetOf<String>()
|
||||
private val samplingSubscriptionIds = mutableMapOf<String, String>()
|
||||
private val liveSamplingSubscriptionGeohashes = mutableSetOf<String>()
|
||||
private var requestedLiveSamplingGeohashes: Set<String> = emptySet()
|
||||
private var requestedUserSamplingGeohashes: Set<String> = emptySet()
|
||||
private val liveLocationRevocationListener: () -> Unit = {
|
||||
val revokedLiveGeohashes = liveSamplingSubscriptionGeohashes.toSet()
|
||||
revokedLiveGeohashes.forEach { geohash ->
|
||||
samplingSubscriptionIds.remove(geohash)
|
||||
activeSamplingGeohashes.remove(geohash)
|
||||
}
|
||||
liveSamplingSubscriptionGeohashes.clear()
|
||||
requestedLiveSamplingGeohashes = emptySet()
|
||||
}
|
||||
|
||||
// Geohash of the currently selected Location channel (null for Mesh/none).
|
||||
private var activeChannelGeohash: String? = null
|
||||
@ -81,6 +100,10 @@ class GeohashViewModel(
|
||||
val geohashParticipantCounts: StateFlow<Map<String, Int>> = state.geohashParticipantCounts
|
||||
val selectedLocationChannel: StateFlow<com.bitchat.android.geohash.ChannelID?> = state.selectedLocationChannel
|
||||
|
||||
init {
|
||||
LiveLocationPrivacyGate.addRevocationListener(liveLocationRevocationListener)
|
||||
}
|
||||
|
||||
fun initialize() {
|
||||
subscriptionManager.connect()
|
||||
// Observe process lifecycle to manage background sampling
|
||||
@ -126,10 +149,13 @@ class GeohashViewModel(
|
||||
private fun startGlobalPresenceHeartbeat() {
|
||||
globalPresenceJob?.cancel()
|
||||
globalPresenceJob = viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) {
|
||||
// Reactively restart heartbeat whenever available channels change
|
||||
locationChannelManager?.availableChannels?.collectLatest { channels ->
|
||||
// Filter for REGION (2), PROVINCE (4), CITY (5) - precision <= 5
|
||||
val targetGeohashes = channels.filter { it.level.precision <= 5 }.map { it.geohash }
|
||||
val manager = locationChannelManager ?: return@launch
|
||||
combine(
|
||||
manager.availableChannels,
|
||||
LiveLocationPrivacyGate.enabled
|
||||
) { channels, enabled ->
|
||||
GeohashNostrPrivacyPolicy.livePresenceTargets(channels, enabled)
|
||||
}.collectLatest { targetGeohashes ->
|
||||
|
||||
if (targetGeohashes.isNotEmpty()) {
|
||||
// Enter heartbeat loop for this set of channels
|
||||
@ -147,8 +173,10 @@ class GeohashViewModel(
|
||||
delay(stepDelay)
|
||||
timeSpent += stepDelay
|
||||
|
||||
broadcastPresence(geohash)
|
||||
broadcastLiveLocationPresence(geohash)
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Global presence heartbeat error: ${e.message}")
|
||||
}
|
||||
@ -174,6 +202,7 @@ class GeohashViewModel(
|
||||
currentGeohashMsgSubId = null
|
||||
currentGeohashPresenceSubId = null
|
||||
currentDmSubId = null
|
||||
currentDmGeohash = null
|
||||
activeChannelGeohash = null
|
||||
geoTimer?.cancel()
|
||||
geoTimer = null
|
||||
@ -187,22 +216,52 @@ class GeohashViewModel(
|
||||
dmHandler.invalidateDoubleRatchetAccount()
|
||||
}
|
||||
|
||||
private suspend fun broadcastPresence(geohash: String) {
|
||||
private suspend fun broadcastLiveLocationPresence(geohash: String) {
|
||||
val manager = locationChannelManager ?: return
|
||||
val token = LiveLocationPrivacyGate.captureToken() ?: return
|
||||
val isCurrentLiveTarget = GeohashNostrPrivacyPolicy.livePresenceTargets(
|
||||
manager.availableChannels.value,
|
||||
liveLocationEnabled = true
|
||||
).contains(geohash)
|
||||
if (!isCurrentLiveTarget || !LiveLocationPrivacyGate.accepts(token)) return
|
||||
|
||||
try {
|
||||
val identity = NostrIdentityBridge.deriveIdentity(geohash, getApplication())
|
||||
val event = NostrProtocol.createGeohashPresenceEvent(geohash, identity)
|
||||
val relayManager = NostrRelayManager.getInstance(getApplication())
|
||||
// Presence is lightweight, send to geohash relays
|
||||
relayManager.sendEventToGeohash(event, geohash, includeDefaults = false, nRelays = 5)
|
||||
Log.v(TAG, "💓 Sent presence heartbeat for $geohash")
|
||||
var identity: com.bitchat.android.nostr.NostrIdentity? = null
|
||||
LiveLocationPrivacyGate.runIfAllowed(token) {
|
||||
identity = NostrIdentityBridge.deriveIdentity(geohash, getApplication())
|
||||
}
|
||||
val preparedIdentity = identity ?: return
|
||||
if (!LiveLocationPrivacyGate.accepts(token)) return
|
||||
val event = NostrProtocol.createGeohashPresenceEvent(geohash, preparedIdentity)
|
||||
LiveLocationPrivacyGate.runIfAllowed(token) {
|
||||
val relayManager = NostrRelayManager.getInstance(getApplication())
|
||||
relayManager.sendEventToGeohash(
|
||||
event,
|
||||
geohash,
|
||||
includeDefaults = false,
|
||||
nRelays = 5,
|
||||
liveLocationToken = token
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to send presence for $geohash: ${e.message}")
|
||||
Log.w(TAG, "Failed to send live-location presence")
|
||||
}
|
||||
}
|
||||
|
||||
fun sendGeohashMessage(content: String, channel: com.bitchat.android.geohash.GeohashChannel, myPeerID: String, nickname: String?) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val canUseChannel = locationChannelManager
|
||||
?.canUseSelectedLocationChannel(channel) == true
|
||||
if (!canUseChannel) {
|
||||
Log.w(TAG, "Blocked message to a stale live-location channel")
|
||||
return@launch
|
||||
}
|
||||
val isLiveDerived = locationChannelManager
|
||||
?.isSelectedChannelLiveDerived(channel) == true
|
||||
val liveLocationToken = locationChannelManager
|
||||
?.liveLocationTokenForSelectedChannel(channel)
|
||||
if (isLiveDerived && liveLocationToken == null) return@launch
|
||||
val tempId = "temp_${System.currentTimeMillis()}_${kotlin.random.Random.nextInt(1000)}"
|
||||
val pow = PoWPreferenceManager.getCurrentSettings()
|
||||
val localMsg = com.bitchat.android.model.BitchatMessage(
|
||||
@ -216,51 +275,73 @@ class GeohashViewModel(
|
||||
powDifficulty = if (pow.enabled) pow.difficulty else null
|
||||
)
|
||||
messageManager.addChannelMessage("geo:${channel.geohash}", localMsg)
|
||||
val startedMining = pow.enabled && pow.difficulty > 0
|
||||
if (startedMining) {
|
||||
com.bitchat.android.ui.PoWMiningTracker.startMiningMessage(tempId)
|
||||
}
|
||||
try {
|
||||
val identity = NostrIdentityBridge.deriveIdentity(forGeohash = channel.geohash, context = getApplication())
|
||||
val teleported = state.isTeleported.value
|
||||
val event = NostrProtocol.createEphemeralGeohashEvent(content, channel.geohash, identity, nickname, teleported)
|
||||
val relayManager = NostrRelayManager.getInstance(getApplication())
|
||||
relayManager.sendEventToGeohash(event, channel.geohash, includeDefaults = false, nRelays = 5)
|
||||
} finally {
|
||||
// Ensure we stop the per-message mining animation regardless of success/failure
|
||||
if (startedMining) {
|
||||
com.bitchat.android.ui.PoWMiningTracker.stopMiningMessage(tempId)
|
||||
}
|
||||
}
|
||||
val identity = NostrIdentityBridge.deriveIdentity(
|
||||
forGeohash = channel.geohash,
|
||||
context = getApplication()
|
||||
)
|
||||
val teleported = locationChannelManager?.teleported?.value
|
||||
?: state.isTeleported.value
|
||||
val event = NostrProtocol.createEphemeralGeohashEvent(
|
||||
content,
|
||||
channel.geohash,
|
||||
identity,
|
||||
nickname,
|
||||
teleported
|
||||
)
|
||||
val relayManager = NostrRelayManager.getInstance(getApplication())
|
||||
relayManager.sendEventToGeohash(
|
||||
event,
|
||||
channel.geohash,
|
||||
includeDefaults = false,
|
||||
nRelays = 5,
|
||||
liveLocationToken = liveLocationToken
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to send geohash message: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun beginGeohashSampling(geohashes: List<String>) {
|
||||
if (geohashes.isEmpty()) {
|
||||
endGeohashSampling()
|
||||
return
|
||||
}
|
||||
|
||||
// Diffing logic to avoid redundant REQ and leaks
|
||||
fun beginGeohashSampling(
|
||||
liveLocationGeohashes: Collection<String>,
|
||||
userSelectedGeohashes: Collection<String>,
|
||||
) {
|
||||
requestedLiveSamplingGeohashes = liveLocationGeohashes.toSet()
|
||||
requestedUserSamplingGeohashes = userSelectedGeohashes.toSet()
|
||||
reconcileSamplingSubscriptions()
|
||||
}
|
||||
|
||||
private fun reconcileSamplingSubscriptions() {
|
||||
val currentSet = activeSamplingGeohashes.toSet()
|
||||
val newSet = geohashes.toSet()
|
||||
val newSet = GeohashNostrPrivacyPolicy.samplingTargets(
|
||||
liveLocationGeohashes = requestedLiveSamplingGeohashes,
|
||||
userSelectedGeohashes = requestedUserSamplingGeohashes,
|
||||
liveLocationEnabled = LiveLocationPrivacyGate.isEnabled
|
||||
)
|
||||
|
||||
val toRemove = currentSet - newSet
|
||||
val toAdd = newSet - currentSet
|
||||
val toPromoteToUserSelection = currentSet
|
||||
.intersect(requestedUserSamplingGeohashes)
|
||||
.intersect(liveSamplingSubscriptionGeohashes)
|
||||
|
||||
if (toAdd.isEmpty() && toRemove.isEmpty()) return
|
||||
if (toAdd.isEmpty() && toRemove.isEmpty() && toPromoteToUserSelection.isEmpty()) return
|
||||
|
||||
Log.d(TAG, "🌍 Updating sampling: +${toAdd.size} new, -${toRemove.size} removed")
|
||||
|
||||
// Remove old subscriptions
|
||||
toRemove.forEach { geohash ->
|
||||
subscriptionManager.unsubscribe("sampling-$geohash")
|
||||
unsubscribeSampling(geohash)
|
||||
activeSamplingGeohashes.remove(geohash)
|
||||
}
|
||||
|
||||
// A bookmark must remain functional after live access is revoked. Replace a
|
||||
// live-tagged subscription with an untagged manual subscription immediately.
|
||||
toPromoteToUserSelection.forEach { geohash ->
|
||||
unsubscribeSampling(geohash)
|
||||
if (isAppInForeground()) performSubscribeSampling(geohash)
|
||||
}
|
||||
|
||||
// Add new subscriptions
|
||||
activeSamplingGeohashes.addAll(toAdd)
|
||||
if (isAppInForeground()) {
|
||||
@ -271,11 +352,13 @@ class GeohashViewModel(
|
||||
}
|
||||
|
||||
fun endGeohashSampling() {
|
||||
requestedLiveSamplingGeohashes = emptySet()
|
||||
requestedUserSamplingGeohashes = emptySet()
|
||||
if (activeSamplingGeohashes.isEmpty()) return
|
||||
Log.d(TAG, "🌍 Ending geohash sampling (cleaning up ${activeSamplingGeohashes.size} subs)")
|
||||
|
||||
activeSamplingGeohashes.toList().forEach { geohash ->
|
||||
subscriptionManager.unsubscribe("sampling-$geohash")
|
||||
unsubscribeSampling(geohash)
|
||||
}
|
||||
activeSamplingGeohashes.clear()
|
||||
}
|
||||
@ -293,7 +376,7 @@ class GeohashViewModel(
|
||||
GeohashConversationRegistry.set(convKey, gh)
|
||||
}
|
||||
onStartPrivateChat(convKey)
|
||||
Log.d(TAG, "🗨️ Started geohash DM with ${pubkeyHex} -> ${convKey} (geohash=${gh})")
|
||||
Log.d(TAG, "🗨️ Started geohash DM")
|
||||
}
|
||||
|
||||
fun startGeohashDMByNickname(nickname: String, onStartPrivateChat: (String) -> Unit) {
|
||||
@ -346,19 +429,28 @@ class GeohashViewModel(
|
||||
locationChannelManager?.select(channel) ?: run { Log.w(TAG, "Cannot select location channel - not initialized") }
|
||||
}
|
||||
|
||||
fun ensureGeohashDMSubscriptionForConversation(conversationKey: String) {
|
||||
val geohash = repo.getConversationGeohash(conversationKey) ?: return
|
||||
if (currentDmGeohash == geohash && currentDmSubId != null) return
|
||||
|
||||
currentDmSubId?.let(subscriptionManager::unsubscribe)
|
||||
currentDmSubId = null
|
||||
currentDmGeohash = null
|
||||
subscribeChannelDM(geohash)
|
||||
}
|
||||
|
||||
fun displayNameForNostrPubkeyUI(pubkeyHex: String): String = repo.displayNameForNostrPubkeyUI(pubkeyHex)
|
||||
fun displayNameForGeohashConversation(pubkeyHex: String, sourceGeohash: String): String = repo.displayNameForGeohashConversation(pubkeyHex, sourceGeohash)
|
||||
|
||||
fun colorForNostrPubkey(pubkeyHex: String, isDark: Boolean): androidx.compose.ui.graphics.Color {
|
||||
val seed = "nostr:${pubkeyHex.lowercase()}"
|
||||
return colorForPeerSeed(seed, isDark).copy()
|
||||
}
|
||||
fun peerIdentityForNostrPubkey(pubkeyHex: String): PeerIdentity =
|
||||
PeerIdentity.nostr(pubkeyHex)
|
||||
|
||||
private fun switchLocationChannel(channel: com.bitchat.android.geohash.ChannelID?) {
|
||||
geoTimer?.cancel(); geoTimer = null
|
||||
currentGeohashMsgSubId?.let { subscriptionManager.unsubscribe(it); currentGeohashMsgSubId = null }
|
||||
currentGeohashPresenceSubId?.let { subscriptionManager.unsubscribe(it); currentGeohashPresenceSubId = null }
|
||||
currentDmSubId?.let { subscriptionManager.unsubscribe(it); currentDmSubId = null }
|
||||
currentDmGeohash = null
|
||||
|
||||
when (channel) {
|
||||
is com.bitchat.android.geohash.ChannelID.Mesh -> {
|
||||
@ -370,7 +462,12 @@ class GeohashViewModel(
|
||||
repo.refreshGeohashPeople()
|
||||
}
|
||||
is com.bitchat.android.geohash.ChannelID.Location -> {
|
||||
Log.d(TAG, "📍 Switching to geohash channel: ${channel.channel.geohash}")
|
||||
if (locationChannelManager?.canUseSelectedLocationChannel(channel.channel) != true) {
|
||||
Log.w(TAG, "Ignoring a stale live-location channel selection")
|
||||
switchLocationChannel(com.bitchat.android.geohash.ChannelID.Mesh)
|
||||
return
|
||||
}
|
||||
Log.d(TAG, "📍 Switching to geohash channel")
|
||||
activeChannelGeohash = channel.channel.geohash
|
||||
repo.setCurrentGeohash(channel.channel.geohash)
|
||||
repo.refreshGeohashPeople()
|
||||
@ -381,19 +478,22 @@ class GeohashViewModel(
|
||||
try {
|
||||
val identity = NostrIdentityBridge.deriveIdentity(channel.channel.geohash, getApplication())
|
||||
// We don't update participant here anymore; presence loop handles it via Kind 20001
|
||||
val teleported = state.isTeleported.value
|
||||
val teleported = locationChannelManager?.teleported?.value
|
||||
?: state.isTeleported.value
|
||||
if (teleported) repo.markTeleported(identity.publicKeyHex)
|
||||
} catch (e: Exception) { Log.w(TAG, "Failed identity setup: ${e.message}") }
|
||||
|
||||
startGeoParticipantsTimer()
|
||||
val liveLocationToken = locationChannelManager
|
||||
?.liveLocationTokenForSelectedChannel(channel.channel)
|
||||
|
||||
// Chat message stream (kind 20000) is low-volume; keep it alive even when
|
||||
// backgrounded so geohash messages still arrive.
|
||||
subscribeChannelMessages(channel.channel.geohash)
|
||||
subscribeChannelMessages(channel.channel.geohash, liveLocationToken)
|
||||
// Presence heartbeat firehose (kind 20001) is the high-volume data hog; only
|
||||
// run it in the foreground. It is restored in onStart() and torn down in onStop().
|
||||
if (isAppInForeground()) {
|
||||
subscribeChannelPresence(channel.channel.geohash)
|
||||
subscribeChannelPresence(channel.channel.geohash, liveLocationToken)
|
||||
}
|
||||
// Gift-wrap DM subscription is lightweight (filtered to our pubkey) and is
|
||||
// kept alive in the background so geohash DMs still arrive.
|
||||
@ -411,14 +511,18 @@ class GeohashViewModel(
|
||||
* Subscribe to the chat message stream (kind 20000) for a geohash channel.
|
||||
* Low-volume; kept alive in the background so messages keep arriving.
|
||||
*/
|
||||
private fun subscribeChannelMessages(geohash: String) {
|
||||
val subId = "geohash-$geohash"; currentGeohashMsgSubId = subId
|
||||
private fun subscribeChannelMessages(
|
||||
geohash: String,
|
||||
liveLocationToken: Long?
|
||||
) {
|
||||
val subId = "geohash-${UUID.randomUUID()}"; currentGeohashMsgSubId = subId
|
||||
subscriptionManager.subscribeGeohashMessages(
|
||||
geohash = geohash,
|
||||
sinceMs = System.currentTimeMillis() - 3600000L,
|
||||
limit = 200,
|
||||
id = subId,
|
||||
handler = { event -> geohashMessageHandler.onEvent(event, geohash) }
|
||||
handler = { event -> geohashMessageHandler.onEvent(event, geohash) },
|
||||
liveLocationToken = liveLocationToken
|
||||
)
|
||||
}
|
||||
|
||||
@ -427,14 +531,18 @@ class GeohashViewModel(
|
||||
* High-volume; only used to refresh the participant list, so it is torn down in
|
||||
* onStop() and restored in onStart() to cut background mobile data.
|
||||
*/
|
||||
private fun subscribeChannelPresence(geohash: String) {
|
||||
val subId = "geohash-presence-$geohash"; currentGeohashPresenceSubId = subId
|
||||
private fun subscribeChannelPresence(
|
||||
geohash: String,
|
||||
liveLocationToken: Long?
|
||||
) {
|
||||
val subId = "geohash-presence-${UUID.randomUUID()}"; currentGeohashPresenceSubId = subId
|
||||
subscriptionManager.subscribeGeohashPresence(
|
||||
geohash = geohash,
|
||||
sinceMs = System.currentTimeMillis() - 3600000L,
|
||||
limit = 200,
|
||||
id = subId,
|
||||
handler = { event -> geohashMessageHandler.onEvent(event, geohash) }
|
||||
handler = { event -> geohashMessageHandler.onEvent(event, geohash) },
|
||||
liveLocationToken = liveLocationToken
|
||||
)
|
||||
}
|
||||
|
||||
@ -443,18 +551,18 @@ class GeohashViewModel(
|
||||
* Lightweight (filtered to our pubkey); kept alive in the background.
|
||||
*/
|
||||
private fun subscribeChannelDM(geohash: String) {
|
||||
viewModelScope.launch {
|
||||
val dmIdentity = NostrIdentityBridge.deriveIdentity(geohash, getApplication())
|
||||
val dmSubId = "geo-dm-$geohash"; currentDmSubId = dmSubId
|
||||
subscriptionManager.subscribeGiftWraps(
|
||||
pubkey = dmIdentity.publicKeyHex,
|
||||
sinceMs = System.currentTimeMillis() - 172800000L,
|
||||
id = dmSubId,
|
||||
handler = { event -> dmHandler.onGiftWrap(event, geohash, dmIdentity) }
|
||||
)
|
||||
// Also register alias in global registry for routing convenience
|
||||
GeohashAliasRegistry.put("nostr_${dmIdentity.publicKeyHex.take(16)}", dmIdentity.publicKeyHex)
|
||||
}
|
||||
val dmIdentity = NostrIdentityBridge.deriveIdentity(geohash, getApplication())
|
||||
val dmSubId = "geo-dm-${UUID.randomUUID()}"
|
||||
currentDmSubId = dmSubId
|
||||
currentDmGeohash = geohash
|
||||
subscriptionManager.subscribeGiftWraps(
|
||||
pubkey = dmIdentity.publicKeyHex,
|
||||
sinceMs = System.currentTimeMillis() - 172800000L,
|
||||
id = dmSubId,
|
||||
handler = { event -> dmHandler.onGiftWrap(event, geohash, dmIdentity) }
|
||||
)
|
||||
// Also register alias in global registry for routing convenience
|
||||
GeohashAliasRegistry.put("nostr_${dmIdentity.publicKeyHex.take(16)}", dmIdentity.publicKeyHex)
|
||||
}
|
||||
|
||||
private fun startGeoParticipantsTimer() {
|
||||
@ -471,13 +579,29 @@ class GeohashViewModel(
|
||||
kotlin.runCatching {
|
||||
ProcessLifecycleOwner.get().lifecycle.removeObserver(this)
|
||||
}
|
||||
LiveLocationPrivacyGate.removeRevocationListener(liveLocationRevocationListener)
|
||||
}
|
||||
|
||||
override fun onStart(owner: LifecycleOwner) {
|
||||
Log.d(TAG, "🌍 App foregrounded: resuming Nostr streaming")
|
||||
// Android permission may have changed while backgrounded. Invalidate the
|
||||
// process-wide token before restoring any subscription or heartbeat.
|
||||
locationChannelManager?.syncPermissionState()
|
||||
|
||||
// Restore the presence heartbeat firehose for the selected geohash channel.
|
||||
// (The chat message stream is kept alive in the background, so it is not restored here.)
|
||||
activeChannelGeohash?.let { subscribeChannelPresence(it) }
|
||||
val selected = locationChannelManager?.selectedChannel?.value
|
||||
val selectedLocation = selected as? com.bitchat.android.geohash.ChannelID.Location
|
||||
if (selectedLocation != null &&
|
||||
selectedLocation.channel.geohash == activeChannelGeohash &&
|
||||
locationChannelManager?.canUseSelectedLocationChannel(selectedLocation.channel) == true
|
||||
) {
|
||||
subscribeChannelPresence(
|
||||
selectedLocation.channel.geohash,
|
||||
locationChannelManager
|
||||
?.liveLocationTokenForSelectedChannel(selectedLocation.channel)
|
||||
)
|
||||
}
|
||||
// Resume geohash sampling subscriptions
|
||||
activeSamplingGeohashes.forEach { performSubscribeSampling(it) }
|
||||
// Resume the participant-refresh polling timer if a geohash is selected
|
||||
@ -496,7 +620,7 @@ class GeohashViewModel(
|
||||
// The chat message stream (kind 20000) is intentionally left active so messages still arrive.
|
||||
currentGeohashPresenceSubId?.let { subscriptionManager.unsubscribe(it); currentGeohashPresenceSubId = null }
|
||||
// Drop geohash sampling subscriptions
|
||||
activeSamplingGeohashes.forEach { subscriptionManager.unsubscribe("sampling-$it") }
|
||||
activeSamplingGeohashes.forEach(::unsubscribeSampling)
|
||||
// Stop broadcasting presence heartbeats
|
||||
globalPresenceJob?.cancel(); globalPresenceJob = null
|
||||
// Stop participant-refresh polling
|
||||
@ -506,15 +630,48 @@ class GeohashViewModel(
|
||||
}
|
||||
|
||||
private fun performSubscribeSampling(geohash: String) {
|
||||
val subscriptionId = samplingSubscriptionIds.getOrPut(geohash) {
|
||||
"sampling-${UUID.randomUUID()}"
|
||||
}
|
||||
// Sampling only needs participant counts, never message bodies, so it subscribes to
|
||||
// presence heartbeats only (kind 20001) to keep the payload small.
|
||||
subscriptionManager.subscribeGeohashPresence(
|
||||
geohash = geohash,
|
||||
sinceMs = System.currentTimeMillis() - 86400000L,
|
||||
limit = 200,
|
||||
id = "sampling-$geohash",
|
||||
handler = { event -> geohashMessageHandler.onEvent(event, geohash) }
|
||||
)
|
||||
val subscribe = {
|
||||
liveSamplingSubscriptionGeohashes.remove(geohash)
|
||||
subscriptionManager.subscribeGeohashPresence(
|
||||
geohash = geohash,
|
||||
sinceMs = System.currentTimeMillis() - 86400000L,
|
||||
limit = 200,
|
||||
id = subscriptionId,
|
||||
handler = { event -> geohashMessageHandler.onEvent(event, geohash) }
|
||||
)
|
||||
}
|
||||
|
||||
if (geohash in requestedUserSamplingGeohashes) {
|
||||
subscribe()
|
||||
} else if (geohash in requestedLiveSamplingGeohashes) {
|
||||
val isCurrentLiveTarget = locationChannelManager
|
||||
?.availableChannels
|
||||
?.value
|
||||
?.any { it.geohash == geohash } == true
|
||||
if (!isCurrentLiveTarget) return
|
||||
val token = LiveLocationPrivacyGate.captureToken() ?: return
|
||||
LiveLocationPrivacyGate.runIfAllowed(token) {
|
||||
liveSamplingSubscriptionGeohashes.add(geohash)
|
||||
subscriptionManager.subscribeGeohashPresence(
|
||||
geohash = geohash,
|
||||
sinceMs = System.currentTimeMillis() - 86400000L,
|
||||
limit = 200,
|
||||
id = subscriptionId,
|
||||
handler = { event -> geohashMessageHandler.onEvent(event, geohash) },
|
||||
liveLocationToken = token
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun unsubscribeSampling(geohash: String) {
|
||||
samplingSubscriptionIds.remove(geohash)?.let(subscriptionManager::unsubscribe)
|
||||
liveSamplingSubscriptionGeohashes.remove(geohash)
|
||||
}
|
||||
|
||||
private fun isAppInForeground(): Boolean {
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
package com.bitchat.android.ui
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Link
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.background
|
||||
@ -7,8 +9,6 @@ import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Link
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
@ -17,11 +17,11 @@ import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.bitchat.android.ui.theme.BitchatFontFamily
|
||||
import com.bitchat.android.ui.theme.BASE_FONT_SIZE
|
||||
import java.net.URL
|
||||
|
||||
@ -43,11 +43,8 @@ fun LinkPreviewPill(
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
|
||||
|
||||
// iOS-style colors
|
||||
val textColor = if (isDark) Color.Green else Color(red = 0f, green = 0.5f, blue = 0f)
|
||||
val backgroundColor = if (isDark) Color.Gray.copy(alpha = 0.15f) else Color.Gray.copy(alpha = 0.08f)
|
||||
val textColor = colorScheme.secondary
|
||||
val backgroundColor = colorScheme.secondaryContainer.copy(alpha = 0.35f)
|
||||
val borderColor = textColor.copy(alpha = 0.3f)
|
||||
|
||||
// Parse URL for host extraction
|
||||
@ -87,7 +84,7 @@ fun LinkPreviewPill(
|
||||
Surface(
|
||||
modifier = Modifier.size(60.dp),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = Color.Blue.copy(alpha = 0.1f)
|
||||
color = colorScheme.secondaryContainer
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
@ -97,7 +94,7 @@ fun LinkPreviewPill(
|
||||
imageVector = Icons.Outlined.Link,
|
||||
contentDescription = stringResource(com.bitchat.android.R.string.cd_link),
|
||||
modifier = Modifier.size(24.dp),
|
||||
tint = Color.Blue
|
||||
tint = colorScheme.secondary
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -110,7 +107,7 @@ fun LinkPreviewPill(
|
||||
// Title - matches iOS styling
|
||||
Text(
|
||||
text = displayTitle,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = BASE_FONT_SIZE.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = textColor,
|
||||
@ -121,7 +118,7 @@ fun LinkPreviewPill(
|
||||
// Host - matches iOS styling
|
||||
Text(
|
||||
text = displayHost,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 11.sp,
|
||||
color = textColor.copy(alpha = 0.6f),
|
||||
maxLines = 1,
|
||||
|
||||
@ -1,19 +1,21 @@
|
||||
package com.bitchat.android.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Description
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.bitchat.android.R
|
||||
import com.bitchat.android.geohash.ChannelID
|
||||
@ -21,9 +23,9 @@ import com.bitchat.android.geohash.LocationChannelManager
|
||||
import com.bitchat.android.nostr.LocationNotesManager
|
||||
|
||||
/**
|
||||
* Location Notes button component for MainHeader
|
||||
* Shows in mesh mode when location permission granted AND services enabled
|
||||
* Icon turns primary color when notes exist, gray otherwise
|
||||
* Location Notes button for MainHeader.
|
||||
* Mesh-only with location authorized. Tor health tints the glyph with muted colours
|
||||
* and a slow glow while connecting (via [rememberTorConnectionVisual]).
|
||||
*/
|
||||
@Composable
|
||||
fun LocationNotesButton(
|
||||
@ -33,18 +35,15 @@ fun LocationNotesButton(
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val context = LocalContext.current
|
||||
|
||||
// Get channel and permission state
|
||||
|
||||
val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
|
||||
val locationManager = remember { LocationChannelManager.getInstance(context) }
|
||||
val permissionState by locationManager.permissionState.collectAsStateWithLifecycle()
|
||||
val locationServicesEnabled by locationManager.effectiveLocationEnabled.collectAsStateWithLifecycle(false)
|
||||
|
||||
// Check both permission AND location services enabled
|
||||
val locationPermissionGranted = permissionState == LocationChannelManager.PermissionState.AUTHORIZED
|
||||
val locationEnabled = locationPermissionGranted && locationServicesEnabled
|
||||
|
||||
// Get notes count from LocationNotesManager
|
||||
|
||||
val notesManager = remember { LocationNotesManager.getInstance() }
|
||||
val notes by notesManager.notes.collectAsStateWithLifecycle()
|
||||
val notesCount = notes.size
|
||||
@ -52,15 +51,22 @@ fun LocationNotesButton(
|
||||
// Only show in mesh mode when location is authorized (iOS pattern)
|
||||
if (selectedLocationChannel is ChannelID.Mesh && locationEnabled) {
|
||||
val hasNotes = notesCount > 0
|
||||
IconButton(
|
||||
onClick = onClick,
|
||||
modifier = modifier.size(24.dp)
|
||||
val contentDescription = stringResource(R.string.cd_location_notes)
|
||||
val normalTint = if (hasNotes) colorScheme.primary else colorScheme.onSurfaceVariant
|
||||
val torVisual = rememberTorConnectionVisual(normal = normalTint)
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.size(44.dp)
|
||||
.clip(CircleShape)
|
||||
.pressScaleClickable(onClick = onClick, onClickLabel = contentDescription),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Description, // "long.text.page.and.pencil" equivalent
|
||||
contentDescription = stringResource(R.string.cd_location_notes),
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = if (hasNotes) colorScheme.primary else Color.Gray
|
||||
TorAwareHeaderIcon(
|
||||
painter = painterResource(R.drawable.ic_spec_chat_bubbles),
|
||||
tint = torVisual.tint,
|
||||
isProgress = torVisual.isProgress,
|
||||
contentDescription = contentDescription
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,16 +1,15 @@
|
||||
package com.bitchat.android.ui
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowUpward
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowUpward
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.getValue
|
||||
@ -18,9 +17,9 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import com.bitchat.android.ui.theme.BitchatFontFamily
|
||||
import com.bitchat.android.R
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
@ -51,10 +50,8 @@ fun LocationNotesSheet(
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val isDark = isSystemInDarkTheme()
|
||||
|
||||
// iOS color scheme
|
||||
val accentGreen = if (isDark) Color.Green else Color(0xFF008000) // dark: green, light: dark green (0, 0.5, 0)
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val accentGreen = colorScheme.primary
|
||||
|
||||
// Managers
|
||||
val notesManager = remember { LocationNotesManager.getInstance() }
|
||||
@ -251,7 +248,7 @@ private fun LocationNotesHeader(
|
||||
if (name.isNotEmpty()) {
|
||||
Text(
|
||||
text = name,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 12.sp,
|
||||
color = accentGreen
|
||||
)
|
||||
@ -262,7 +259,7 @@ private fun LocationNotesHeader(
|
||||
// Description
|
||||
Text(
|
||||
text = stringResource(R.string.location_notes_description),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
|
||||
)
|
||||
@ -272,7 +269,7 @@ private fun LocationNotesHeader(
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.location_notes_relays_unavailable),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 11.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
|
||||
)
|
||||
@ -303,7 +300,7 @@ private fun NoteRow(note: LocationNotesManager.Note) {
|
||||
) {
|
||||
Text(
|
||||
text = "@$baseName",
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
@ -312,7 +309,7 @@ private fun NoteRow(note: LocationNotesManager.Note) {
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(
|
||||
text = ts,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 11.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
|
||||
)
|
||||
@ -324,7 +321,7 @@ private fun NoteRow(note: LocationNotesManager.Note) {
|
||||
// Second row: content
|
||||
Text(
|
||||
text = note.content,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 14.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
@ -343,7 +340,7 @@ private fun NoRelaysRow(onRetry: () -> Unit) {
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.location_notes_no_relays_title),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
@ -351,14 +348,14 @@ private fun NoRelaysRow(onRetry: () -> Unit) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.location_notes_no_relays_desc),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.retry),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.clickable(onClick = onRetry)
|
||||
@ -385,7 +382,7 @@ private fun LoadingRow() {
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.loading_location_notes),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
|
||||
)
|
||||
@ -404,7 +401,7 @@ private fun EmptyRow() {
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.location_notes_empty_title),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
@ -412,7 +409,7 @@ private fun EmptyRow() {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.location_notes_empty_desc),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
|
||||
)
|
||||
@ -440,7 +437,7 @@ private fun ErrorRow(message: String, onDismiss: () -> Unit) {
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(
|
||||
text = message,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
@ -448,7 +445,7 @@ private fun ErrorRow(message: String, onDismiss: () -> Unit) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.dismiss),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.clickable(onClick = onDismiss)
|
||||
@ -468,7 +465,6 @@ private fun LocationNotesInputSection(
|
||||
nickname: String?,
|
||||
onSend: () -> Unit
|
||||
) {
|
||||
val isDark = isSystemInDarkTheme()
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
|
||||
Column(
|
||||
@ -482,7 +478,7 @@ private fun LocationNotesInputSection(
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = "@$baseName",
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = colorScheme.onSurface
|
||||
@ -506,7 +502,7 @@ private fun LocationNotesInputSection(
|
||||
onValueChange = onDraftChange,
|
||||
textStyle = MaterialTheme.typography.bodyMedium.copy(
|
||||
color = colorScheme.primary,
|
||||
fontFamily = FontFamily.Monospace
|
||||
fontFamily = BitchatFontFamily
|
||||
),
|
||||
cursorBrush = androidx.compose.ui.graphics.SolidColor(colorScheme.primary),
|
||||
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(
|
||||
@ -523,7 +519,7 @@ private fun LocationNotesInputSection(
|
||||
Text(
|
||||
text = stringResource(R.string.location_notes_input_placeholder),
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace
|
||||
fontFamily = BitchatFontFamily
|
||||
),
|
||||
color = colorScheme.onSurface.copy(alpha = 0.5f),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
@ -556,10 +552,8 @@ private fun LocationNotesInputSection(
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = if (!sendButtonEnabled) {
|
||||
colorScheme.onSurface.copy(alpha = 0.5f)
|
||||
} else if (isDark) {
|
||||
Color.Black // Black arrow on green in dark theme
|
||||
} else {
|
||||
Color.White // White arrow on green in light theme
|
||||
colorScheme.onPrimary
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,179 +0,0 @@
|
||||
package com.bitchat.android.ui
|
||||
|
||||
import androidx.compose.material3.ColorScheme
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.bitchat.android.mesh.MeshService
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import java.text.SimpleDateFormat
|
||||
import kotlin.random.Random
|
||||
|
||||
/**
|
||||
* Animation state for individual characters
|
||||
*/
|
||||
private enum class CharacterAnimationState {
|
||||
ENCRYPTED, // Showing random encrypted characters
|
||||
FINAL // Showing final decrypted character
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a message should be animated based on its mining state
|
||||
*/
|
||||
@Composable
|
||||
fun shouldAnimateMessage(messageId: String): Boolean {
|
||||
val miningMessages by PoWMiningTracker.miningMessages.collectAsStateWithLifecycle()
|
||||
return miningMessages.contains(messageId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks which messages are currently being mined for PoW
|
||||
* Provides reactive state for UI animations
|
||||
*/
|
||||
object PoWMiningTracker {
|
||||
private val _miningMessages = MutableStateFlow<Set<String>>(emptySet())
|
||||
val miningMessages: StateFlow<Set<String>> = _miningMessages.asStateFlow()
|
||||
|
||||
/**
|
||||
* Start tracking a message as mining
|
||||
*/
|
||||
fun startMiningMessage(messageId: String) {
|
||||
_miningMessages.value = _miningMessages.value + messageId
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop tracking a message as mining
|
||||
*/
|
||||
fun stopMiningMessage(messageId: String) {
|
||||
_miningMessages.value = _miningMessages.value - messageId
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a message is currently mining
|
||||
*/
|
||||
fun isMiningMessage(messageId: String): Boolean {
|
||||
return _miningMessages.value.contains(messageId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all mining messages (for cleanup)
|
||||
*/
|
||||
fun clearAllMining() {
|
||||
_miningMessages.value = emptySet()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows the active PoW animation inside the same two-row layout used by static text messages.
|
||||
*/
|
||||
@Composable
|
||||
fun MessageWithMatrixAnimation(
|
||||
message: BitchatMessage,
|
||||
currentUserNickname: String,
|
||||
meshService: MeshService,
|
||||
colorScheme: ColorScheme,
|
||||
timeFormatter: SimpleDateFormat,
|
||||
onNicknameClick: ((String) -> Unit)?,
|
||||
onMessageLongPress: ((BitchatMessage) -> Unit)?,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
AnimatedMessageDisplay(
|
||||
message = message,
|
||||
currentUserNickname = currentUserNickname,
|
||||
meshService = meshService,
|
||||
colorScheme = colorScheme,
|
||||
timeFormatter = timeFormatter,
|
||||
onNicknameClick = onNicknameClick,
|
||||
onMessageLongPress = onMessageLongPress,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Animates only the body content; sender, metadata, gestures, and spacing remain stable.
|
||||
*/
|
||||
@Composable
|
||||
private fun AnimatedMessageDisplay(
|
||||
message: BitchatMessage,
|
||||
currentUserNickname: String,
|
||||
meshService: MeshService,
|
||||
colorScheme: ColorScheme,
|
||||
timeFormatter: SimpleDateFormat,
|
||||
onNicknameClick: ((String) -> Unit)?,
|
||||
onMessageLongPress: ((BitchatMessage) -> Unit)?,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var animatedContent by remember(message.id, message.content) {
|
||||
mutableStateOf(message.content)
|
||||
}
|
||||
|
||||
// Character-by-character animation state like the JavaScript version
|
||||
var characterStates by remember(message.id, message.content) {
|
||||
mutableStateOf(message.content.map { char ->
|
||||
if (char == ' ') CharacterAnimationState.FINAL else CharacterAnimationState.ENCRYPTED
|
||||
})
|
||||
}
|
||||
|
||||
LaunchedEffect(message.id, message.content) {
|
||||
if (message.content.isEmpty()) return@LaunchedEffect
|
||||
|
||||
val encryptedChars = "!@$%^&*()_+-=[]{}|;:,<>?".toCharArray()
|
||||
|
||||
// Start character animations with staggered delays (like JS version).
|
||||
message.content.forEachIndexed { index, targetChar ->
|
||||
if (targetChar != ' ') {
|
||||
launch {
|
||||
delay(index * 50L)
|
||||
|
||||
while (true) {
|
||||
while (characterStates.getOrNull(index) == CharacterAnimationState.ENCRYPTED) {
|
||||
val newContent = animatedContent.toCharArray()
|
||||
if (index < newContent.size) {
|
||||
newContent[index] = encryptedChars[Random.nextInt(encryptedChars.size)]
|
||||
animatedContent = String(newContent)
|
||||
}
|
||||
|
||||
delay(100L)
|
||||
|
||||
if (Random.nextFloat() < 0.1f) {
|
||||
val finalContent = animatedContent.toCharArray()
|
||||
if (index < finalContent.size) {
|
||||
finalContent[index] = targetChar
|
||||
animatedContent = String(finalContent)
|
||||
}
|
||||
|
||||
val finalStates = characterStates.toMutableList()
|
||||
finalStates[index] = CharacterAnimationState.FINAL
|
||||
characterStates = finalStates
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
delay(2000L)
|
||||
|
||||
val resetStates = characterStates.toMutableList()
|
||||
resetStates[index] = CharacterAnimationState.ENCRYPTED
|
||||
characterStates = resetStates
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TextMessageLayout(
|
||||
message = message,
|
||||
currentUserNickname = currentUserNickname,
|
||||
meshService = meshService,
|
||||
colorScheme = colorScheme,
|
||||
timeFormatter = timeFormatter,
|
||||
onNicknameClick = onNicknameClick,
|
||||
onMessageLongPress = onMessageLongPress,
|
||||
modifier = modifier,
|
||||
bodyContent = animatedContent,
|
||||
)
|
||||
}
|
||||
@ -1,26 +1,35 @@
|
||||
package com.bitchat.android.ui
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
import com.bitchat.android.ui.theme.BitchatFontFamily
|
||||
import com.bitchat.android.R
|
||||
import android.util.Log
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.animation.scaleOut
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
@ -29,6 +38,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.bitchat.android.core.ui.component.button.CloseButton
|
||||
import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet
|
||||
import com.bitchat.android.core.ui.component.sheet.BitchatSheetCenterTopBar
|
||||
import com.bitchat.android.core.ui.component.sheet.LocalSheetDismiss
|
||||
import com.bitchat.android.core.ui.component.sheet.BitchatSheetTitle
|
||||
import com.bitchat.android.core.ui.component.sheet.BitchatSheetTopBar
|
||||
import com.bitchat.android.favorites.FavoriteRelationship
|
||||
@ -36,6 +46,9 @@ import com.bitchat.android.favorites.FavoritesPersistenceService
|
||||
import com.bitchat.android.geohash.ChannelID
|
||||
import com.bitchat.android.identity.SecureIdentityStateManager
|
||||
import com.bitchat.android.ui.theme.BASE_FONT_SIZE
|
||||
import com.bitchat.android.ui.theme.BitchatMotion
|
||||
import com.bitchat.android.ui.theme.LocalBitchatPalette
|
||||
import com.bitchat.android.ui.theme.colorForPeer
|
||||
import com.bitchat.android.nostr.GeohashAliasRegistry
|
||||
import com.bitchat.android.nostr.GeohashConversationRegistry
|
||||
import com.bitchat.android.services.ContactDirectory
|
||||
@ -68,6 +81,8 @@ fun MeshPeerListSheet(
|
||||
val peerNicknames by viewModel.peerNicknames.collectAsStateWithLifecycle()
|
||||
val peerRSSI by viewModel.peerRSSI.collectAsStateWithLifecycle()
|
||||
val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
|
||||
val geohashPeople by viewModel.geohashPeople.collectAsStateWithLifecycle()
|
||||
val geohashPeopleCount = geohashPeople.size
|
||||
val wifiAwareConnected by com.bitchat.android.wifiaware.WifiAwareController.connectedPeers.collectAsStateWithLifecycle()
|
||||
val wifiAwarePeerIDs = remember(wifiAwareConnected) { wifiAwareConnected.keys.toSet() }
|
||||
|
||||
@ -98,74 +113,83 @@ fun MeshPeerListSheet(
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(top = 64.dp, bottom = 20.dp)
|
||||
contentPadding = PaddingValues(top = 72.dp, bottom = 32.dp)
|
||||
) {
|
||||
val peopleCount = when (selectedLocationChannel) {
|
||||
is ChannelID.Location -> geohashPeopleCount
|
||||
else -> connectedPeers.count { it != viewModel.myPeerID }
|
||||
}
|
||||
|
||||
// Channels section
|
||||
if (joinedChannels.isNotEmpty()) {
|
||||
item(key = "channels_header") {
|
||||
Text(
|
||||
text = stringResource(id = R.string.channels).uppercase(),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = colorScheme.onSurface.copy(alpha = 0.7f),
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp)
|
||||
.padding(top = 8.dp, bottom = 4.dp)
|
||||
)
|
||||
}
|
||||
|
||||
items(
|
||||
items = joinedChannels.toList(),
|
||||
key = { "channel_$it" }
|
||||
) { channel ->
|
||||
val isSelected = channel == currentChannel
|
||||
val unreadCount = unreadChannelMessages[channel] ?: 0
|
||||
|
||||
ChannelRow(
|
||||
channel = channel,
|
||||
isSelected = isSelected,
|
||||
unreadCount = unreadCount,
|
||||
colorScheme = colorScheme,
|
||||
onChannelClick = {
|
||||
// Check if this is a DM channel (starts with @)
|
||||
if (channel.startsWith("@")) {
|
||||
// Extract peer name and find the peer ID
|
||||
val peerName = channel.removePrefix("@")
|
||||
val peerID =
|
||||
peerNicknames.entries.firstOrNull { it.value == peerName }?.key
|
||||
if (peerID != null) {
|
||||
viewModel.showPrivateChatSheet(peerID)
|
||||
onDismiss()
|
||||
item(key = "channels_section") {
|
||||
Column {
|
||||
SheetIconSectionHeader(
|
||||
iconRes = R.drawable.ic_spec_chat_bubbles,
|
||||
title = stringResource(R.string.channels),
|
||||
modifier = Modifier.padding(top = 8.dp)
|
||||
)
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = AboutHorizontalPadding)
|
||||
.padding(top = 10.dp),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
shape = AboutCardShape
|
||||
) {
|
||||
Column {
|
||||
joinedChannels.toList().forEachIndexed { index, channel ->
|
||||
if (index > 0) SheetCardDivider()
|
||||
val isSelected = channel == currentChannel
|
||||
val unreadCount = unreadChannelMessages[channel] ?: 0
|
||||
ChannelRow(
|
||||
channel = channel,
|
||||
isSelected = isSelected,
|
||||
unreadCount = unreadCount,
|
||||
colorScheme = colorScheme,
|
||||
onChannelClick = {
|
||||
if (channel.startsWith("@")) {
|
||||
val peerName = channel.removePrefix("@")
|
||||
val peerID =
|
||||
peerNicknames.entries.firstOrNull { it.value == peerName }?.key
|
||||
if (peerID != null) {
|
||||
viewModel.showPrivateChatSheet(peerID)
|
||||
onDismiss()
|
||||
}
|
||||
} else {
|
||||
viewModel.switchToChannel(channel)
|
||||
onDismiss()
|
||||
}
|
||||
},
|
||||
onLeaveChannel = {
|
||||
viewModel.leaveChannel(channel)
|
||||
},
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// Regular channel switch
|
||||
viewModel.switchToChannel(channel)
|
||||
onDismiss()
|
||||
}
|
||||
},
|
||||
onLeaveChannel = {
|
||||
viewModel.leaveChannel(channel)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// People section - switch between mesh and geohash lists (iOS-compatible)
|
||||
// People / geohash participants
|
||||
item(key = "people_section") {
|
||||
when (selectedLocationChannel) {
|
||||
is ChannelID.Location -> {
|
||||
// Show geohash people list when in location channel
|
||||
GeohashPeopleList(
|
||||
viewModel = viewModel,
|
||||
onTapPerson = onDismiss
|
||||
onTapPerson = onDismiss,
|
||||
modifier = Modifier.padding(
|
||||
top = if (joinedChannels.isNotEmpty()) 20.dp else 8.dp
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
// Show mesh peer list when in mesh channel (default)
|
||||
PeopleSection(
|
||||
modifier = Modifier.padding(top = if (joinedChannels.isNotEmpty()) 16.dp else 0.dp),
|
||||
modifier = Modifier.padding(
|
||||
top = if (joinedChannels.isNotEmpty()) 20.dp else 8.dp
|
||||
),
|
||||
connectedPeers = connectedPeers,
|
||||
peerNicknames = peerNicknames,
|
||||
peerRSSI = peerRSSI,
|
||||
@ -173,6 +197,7 @@ fun MeshPeerListSheet(
|
||||
colorScheme = colorScheme,
|
||||
selectedPrivatePeer = selectedPrivatePeer,
|
||||
wifiAwarePeerIDs = wifiAwarePeerIDs,
|
||||
peopleCount = peopleCount,
|
||||
viewModel = viewModel,
|
||||
onPrivateChatStart = { peerID ->
|
||||
viewModel.showPrivateChatSheet(peerID)
|
||||
@ -194,13 +219,13 @@ fun MeshPeerListSheet(
|
||||
if (selectedLocationChannel !is ChannelID.Location) {
|
||||
IconButton(
|
||||
onClick = onShowVerification,
|
||||
modifier = Modifier.size(24.dp)
|
||||
modifier = Modifier.size(44.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.QrCode,
|
||||
contentDescription = stringResource(R.string.verify_title),
|
||||
tint = colorScheme.onSurface.copy(alpha = 0.8f),
|
||||
modifier = Modifier.size(18.dp)
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(22.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -213,6 +238,9 @@ fun MeshPeerListSheet(
|
||||
}
|
||||
}
|
||||
|
||||
/** Icon size for trailing actions on peer rows (matches settings glyph scale). */
|
||||
private val PeerRowIconSize = 22.dp
|
||||
|
||||
@Composable
|
||||
private fun ChannelRow(
|
||||
channel: String,
|
||||
@ -222,59 +250,52 @@ private fun ChannelRow(
|
||||
onChannelClick: () -> Unit,
|
||||
onLeaveChannel: () -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
onClick = onChannelClick,
|
||||
color = if (isSelected) {
|
||||
colorScheme.primaryContainer.copy(alpha = 0.15f)
|
||||
} else {
|
||||
Color.Transparent
|
||||
},
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
val palette = LocalBitchatPalette.current
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp, vertical = 2.dp)
|
||||
.clickable(onClick = onChannelClick)
|
||||
.padding(horizontal = SheetRowHorizontal, vertical = SheetRowVertical),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
Box(
|
||||
modifier = Modifier.size(SheetRowLeadingSlot),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.weight(1f),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Unread badge
|
||||
if (unreadCount > 0) {
|
||||
UnreadBadge(
|
||||
count = unreadCount,
|
||||
colorScheme = colorScheme
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = channel,
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = BASE_FONT_SIZE.sp
|
||||
),
|
||||
color = if (isSelected) colorScheme.primary else colorScheme.onSurface,
|
||||
fontWeight = if (isSelected) FontWeight.Medium else FontWeight.Normal
|
||||
if (isSelected) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(SheetRowSelectedDot)
|
||||
.background(colorScheme.primary, CircleShape)
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Leave channel button
|
||||
CloseButton(
|
||||
onClick = onLeaveChannel,
|
||||
} else if (unreadCount > 0) {
|
||||
UnreadBadge(count = unreadCount, colorScheme = colorScheme)
|
||||
} else {
|
||||
Text(
|
||||
text = "#",
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = palette.textTertiary
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(SheetRowLeadingGutter))
|
||||
|
||||
Text(
|
||||
text = channel,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 14.sp,
|
||||
color = if (isSelected) colorScheme.primary else colorScheme.onSurface,
|
||||
fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Medium,
|
||||
modifier = Modifier.weight(1f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
|
||||
CloseButton(onClick = onLeaveChannel)
|
||||
}
|
||||
}
|
||||
|
||||
@ -290,6 +311,7 @@ fun PeopleSection(
|
||||
colorScheme: ColorScheme,
|
||||
selectedPrivatePeer: String?,
|
||||
wifiAwarePeerIDs: Set<String> = emptySet(),
|
||||
peopleCount: Int = 0,
|
||||
viewModel: ChatViewModel,
|
||||
onPrivateChatStart: (String) -> Unit
|
||||
) {
|
||||
@ -298,31 +320,34 @@ fun PeopleSection(
|
||||
SecureIdentityStateManager(context.applicationContext)
|
||||
}
|
||||
|
||||
val palette = LocalBitchatPalette.current
|
||||
|
||||
Column(modifier = modifier) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.people).uppercase(),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = colorScheme.onSurface.copy(alpha = 0.7f),
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp)
|
||||
.padding(top = 8.dp, bottom = 4.dp)
|
||||
SheetIconSectionHeader(
|
||||
iconRes = R.drawable.ic_spec_people,
|
||||
title = stringResource(R.string.people_count_title, peopleCount)
|
||||
)
|
||||
|
||||
if (connectedPeers.isEmpty()) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.no_one_connected),
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 12.sp
|
||||
),
|
||||
color = colorScheme.onSurface.copy(alpha = 0.5f),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 40.dp, vertical = 12.dp)
|
||||
)
|
||||
}
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = AboutHorizontalPadding)
|
||||
.padding(top = 10.dp),
|
||||
color = colorScheme.surface,
|
||||
shape = AboutCardShape
|
||||
) {
|
||||
Column {
|
||||
if (connectedPeers.isEmpty()) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.no_one_connected),
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 12.sp,
|
||||
color = palette.textTertiary,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = SheetRowHorizontal, vertical = SheetRowVertical)
|
||||
)
|
||||
}
|
||||
|
||||
// Observe reactive state for favorites and fingerprints
|
||||
val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.collectAsStateWithLifecycle()
|
||||
@ -426,7 +451,21 @@ fun PeopleSection(
|
||||
if (b != "You") baseNameCounts[b] = (baseNameCounts[b] ?: 0) + 1
|
||||
}
|
||||
|
||||
sortedPeers.forEach { peerID ->
|
||||
// Every row this card will show, in final order, so the animated list can key on identity
|
||||
// and animate reordering. Offline favourites are appended after the connected peers.
|
||||
// Collected once for the whole card rather than once per row.
|
||||
val directMap by viewModel.peerDirect.collectAsStateWithLifecycle()
|
||||
|
||||
val offlineFavoriteRows = offlineFavorites.filterNot { isFavoriteMappedToConnected(it) }
|
||||
val rowKeys: List<String> = sortedPeers +
|
||||
offlineFavoriteRows.map { ContactIdentityResolver.noiseKeyHex(it.peerNoisePublicKey) }
|
||||
|
||||
AnimatedRowColumn(items = rowKeys, key = { it }) { rowIndex, rowKey ->
|
||||
Column {
|
||||
if (rowIndex > 0) SheetCardDivider()
|
||||
val connectedPeerForRow = sortedPeers.firstOrNull { it == rowKey }
|
||||
if (connectedPeerForRow != null) {
|
||||
val peerID = connectedPeerForRow
|
||||
val conversationID = ContactDirectory.canonicalConversationId(peerID)
|
||||
val isFavorite = peerFavoriteStates[peerID] ?: false
|
||||
val isVerified = peerVerifiedStates[peerID] ?: false
|
||||
@ -446,7 +485,6 @@ fun PeopleSection(
|
||||
val (bName, _) = splitSuffix(displayName)
|
||||
val showHash = (baseNameCounts[bName] ?: 0) > 1
|
||||
|
||||
val directMap by viewModel.peerDirect.collectAsStateWithLifecycle()
|
||||
val isDirectLive = directMap[peerID] ?: try { viewModel.getMeshPeerInfo(peerID)?.isDirectConnection == true } catch (_: Exception) { false }
|
||||
PeerItem(
|
||||
peerID = peerID,
|
||||
@ -468,12 +506,12 @@ fun PeopleSection(
|
||||
showNostrGlobe = false,
|
||||
showHashSuffix = showHash
|
||||
)
|
||||
}
|
||||
|
||||
// Append offline favorites we actively favorite (and not currently connected)
|
||||
offlineFavorites.forEach { fav ->
|
||||
val favPeerID = ContactIdentityResolver.noiseKeyHex(fav.peerNoisePublicKey)
|
||||
if (isFavoriteMappedToConnected(fav)) return@forEach
|
||||
} else {
|
||||
// Offline favourite: still worth showing, reachable over Nostr.
|
||||
val fav = offlineFavoriteRows.first {
|
||||
ContactIdentityResolver.noiseKeyHex(it.peerNoisePublicKey) == rowKey
|
||||
}
|
||||
val favPeerID = rowKey
|
||||
|
||||
val nostrConvKey: String? = try {
|
||||
FavoritesPersistenceService.shared.findNostrPubkey(fav.peerNoisePublicKey)
|
||||
@ -518,6 +556,10 @@ fun PeopleSection(
|
||||
showHashSuffix = showHash
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -547,143 +589,123 @@ private fun PeerItem(
|
||||
val isMe = displayName == "You" || peerID == currentNickname
|
||||
|
||||
// Get consistent peer color (iOS-compatible)
|
||||
val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
|
||||
val assignedColor = viewModel.colorForMeshPeer(peerID, isDark)
|
||||
val baseColor = if (isMe) Color(0xFFFF9500) else assignedColor
|
||||
val palette = LocalBitchatPalette.current
|
||||
val assignedColor = colorForPeer(
|
||||
viewModel.peerIdentityForMeshPeer(peerID),
|
||||
palette
|
||||
)
|
||||
val baseColor = if (isMe) palette.accentOrange else assignedColor
|
||||
|
||||
Surface(
|
||||
onClick = onItemClick,
|
||||
color = if (isSelected) {
|
||||
colorScheme.primaryContainer.copy(alpha = 0.15f)
|
||||
} else {
|
||||
Color.Transparent
|
||||
},
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp, vertical = 2.dp)
|
||||
.clickable(onClick = onItemClick)
|
||||
.padding(horizontal = SheetRowHorizontal, vertical = SheetRowVertical),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
Box(
|
||||
modifier = Modifier.size(SheetRowLeadingSlot),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.weight(1f),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Connection/status indicator
|
||||
if (hasUnreadDM) {
|
||||
// Show mail icon for unread DMs (iOS orange)
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Email,
|
||||
contentDescription = stringResource(R.string.cd_unread_message),
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = Color(0xFFFF9500) // iOS orange
|
||||
)
|
||||
} else if (showNostrGlobe) {
|
||||
// Purple globe to indicate Nostr availability
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Public,
|
||||
contentDescription = stringResource(R.string.cd_reachable_via_nostr),
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = Color(0xFF9C27B0) // Purple
|
||||
)
|
||||
} else if (!isDirect && isFavorite) {
|
||||
// Offline favorited user: show outlined circle icon
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Circle,
|
||||
contentDescription = stringResource(R.string.cd_offline_favorite),
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = Color.Gray
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = when {
|
||||
isWifiAware -> Icons.Filled.Wifi
|
||||
isDirect -> Icons.Outlined.Bluetooth
|
||||
else -> Icons.Filled.Route
|
||||
},
|
||||
contentDescription = when {
|
||||
isWifiAware -> "Direct Wi-Fi Aware"
|
||||
isDirect -> "Direct Bluetooth"
|
||||
else -> "Routed"
|
||||
},
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = colorScheme.onSurface.copy(alpha = 0.6f)
|
||||
)
|
||||
}
|
||||
|
||||
// Display name with iOS-style color and hashtag suffix support
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
// Base name with peer-specific color
|
||||
Text(
|
||||
text = baseName,
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = BASE_FONT_SIZE.sp,
|
||||
fontWeight = if (isMe) FontWeight.Bold else FontWeight.Normal
|
||||
),
|
||||
color = baseColor,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
|
||||
// Hashtag suffix in lighter shade (iOS-style)
|
||||
if (suffix.isNotEmpty()) {
|
||||
Text(
|
||||
text = suffix,
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = BASE_FONT_SIZE.sp
|
||||
),
|
||||
color = baseColor.copy(alpha = 0.6f)
|
||||
)
|
||||
}
|
||||
|
||||
if (isWifiAware && hasUnreadDM) {
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Wifi,
|
||||
contentDescription = "Direct Wi-Fi Aware",
|
||||
modifier = Modifier.size(13.dp),
|
||||
tint = colorScheme.onSurface.copy(alpha = 0.8f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isVerified) {
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
if (isSelected) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(SheetRowSelectedDot)
|
||||
.background(colorScheme.primary, CircleShape)
|
||||
)
|
||||
} else if (hasUnreadDM) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Verified,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(14.dp),
|
||||
tint = Color(0xFF32D74B) // iOS Green
|
||||
painter = painterResource(R.drawable.ic_spec_envelope),
|
||||
contentDescription = stringResource(R.string.cd_unread_message),
|
||||
modifier = Modifier.size(PeerRowIconSize),
|
||||
tint = palette.accentOrange
|
||||
)
|
||||
} else if (showNostrGlobe) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_spec_globe),
|
||||
contentDescription = stringResource(R.string.cd_reachable_via_nostr),
|
||||
modifier = Modifier.size(PeerRowIconSize),
|
||||
tint = palette.accentPurple
|
||||
)
|
||||
} else if (!isDirect && isFavorite) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Circle,
|
||||
contentDescription = stringResource(R.string.cd_offline_favorite),
|
||||
modifier = Modifier.size(PeerRowIconSize),
|
||||
tint = palette.textTertiary
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
painter = painterResource(
|
||||
conversationTransportIcon(
|
||||
isReachedOverInternet = false,
|
||||
isWifiAware = isWifiAware,
|
||||
isDirect = isDirect
|
||||
)
|
||||
),
|
||||
contentDescription = when {
|
||||
isWifiAware -> "Direct Wi-Fi Aware"
|
||||
isDirect -> "Direct Bluetooth"
|
||||
else -> "Routed"
|
||||
},
|
||||
modifier = Modifier.size(PeerRowIconSize),
|
||||
tint = colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(SheetRowLeadingGutter))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = baseName,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = if (isMe) FontWeight.Bold else FontWeight.Medium,
|
||||
color = baseColor,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
|
||||
if (suffix.isNotEmpty()) {
|
||||
Text(
|
||||
text = suffix,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = if (isMe) FontWeight.Bold else FontWeight.Medium,
|
||||
color = baseColor.copy(alpha = SUFFIX_ALPHA)
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Favorite star with proper filled/outlined states
|
||||
IconButton(
|
||||
onClick = onToggleFavorite,
|
||||
modifier = Modifier.size(32.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (isFavorite) Icons.Filled.Star else Icons.Outlined.Star,
|
||||
contentDescription = if (isFavorite) "Remove from favorites" else "Add to favorites",
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = if (isFavorite) Color(0xFFFFD700) else Color(0xFF4CAF50)
|
||||
)
|
||||
}
|
||||
if (isVerified) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_spec_check),
|
||||
contentDescription = stringResource(R.string.verify_title),
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(36.dp)
|
||||
.clickable(onClick = onToggleFavorite),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(
|
||||
if (isFavorite) R.drawable.ic_spec_star_filled else R.drawable.ic_spec_star
|
||||
),
|
||||
contentDescription = if (isFavorite) "Remove from favorites" else "Add to favorites",
|
||||
modifier = Modifier.size(PeerRowIconSize),
|
||||
tint = if (isFavorite) palette.accentOrange else palette.textTertiary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -696,25 +718,41 @@ private fun UnreadBadge(
|
||||
colorScheme: ColorScheme,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
if (count > 0) {
|
||||
val palette = LocalBitchatPalette.current
|
||||
// Scale/fade in and out so a badge appearing while the sheet is open is noticed, and one
|
||||
// clearing does not just blink away.
|
||||
AnimatedVisibility(
|
||||
visible = count > 0,
|
||||
enter = fadeIn(tween(BitchatMotion.STANDARD_MS)) +
|
||||
scaleIn(
|
||||
initialScale = 0.5f,
|
||||
animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing)
|
||||
),
|
||||
exit = fadeOut(tween(BitchatMotion.QUICK_MS)) +
|
||||
scaleOut(
|
||||
targetScale = 0.5f,
|
||||
animationSpec = tween(BitchatMotion.QUICK_MS, easing = FastOutSlowInEasing)
|
||||
),
|
||||
modifier = modifier
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
modifier = Modifier
|
||||
.background(
|
||||
color = Color(0xFFFFD700), // Yellow color
|
||||
color = palette.accentOrange,
|
||||
shape = RoundedCornerShape(10.dp)
|
||||
)
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||
.defaultMinSize(minWidth = 18.dp, minHeight = 18.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
AnimatedCountLabel(
|
||||
count = count,
|
||||
text = if (count > 99) "99+" else count.toString(),
|
||||
style = MaterialTheme.typography.labelSmall.copy(
|
||||
fontSize = 10.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontFamily = FontFamily.Monospace
|
||||
),
|
||||
color = Color.Black // Black text on yellow background
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontSize = 10.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontFamily = BitchatFontFamily,
|
||||
color = Color.Black
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -804,7 +842,7 @@ fun PrivateChatSheet(
|
||||
val name = if (fullPubkey.isNotEmpty()) {
|
||||
viewModel.geohashViewModel.displayNameForGeohashConversation(fullPubkey, gh)
|
||||
} else {
|
||||
peerNicknames[peerID] ?: "unknown"
|
||||
peerNicknames[peerID] ?: "Unknown"
|
||||
}
|
||||
"#$gh/@$name"
|
||||
} else {
|
||||
@ -826,12 +864,7 @@ fun PrivateChatSheet(
|
||||
viewModel.isPeerVerified(peerID, verifiedFingerprints)
|
||||
}
|
||||
|
||||
val securityModifier = if (!isNostrPeer && !isNostrReachableFavorite) {
|
||||
Modifier.clickable { viewModel.showSecurityVerificationSheet() }
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
|
||||
val palette = LocalBitchatPalette.current
|
||||
val sheetState = rememberModalBottomSheetState(
|
||||
skipPartiallyExpanded = true
|
||||
)
|
||||
@ -845,9 +878,9 @@ fun PrivateChatSheet(
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(64.dp))
|
||||
Spacer(modifier = Modifier.height(ChatHeaderHeight))
|
||||
|
||||
HorizontalDivider(color = colorScheme.outline.copy(alpha = 0.3f))
|
||||
HorizontalDivider(thickness = 1.dp, color = colorScheme.outlineVariant)
|
||||
|
||||
// Messages list
|
||||
var forceScrollToBottom by remember { mutableStateOf(false) }
|
||||
@ -858,6 +891,7 @@ fun PrivateChatSheet(
|
||||
currentUserNickname = nickname,
|
||||
meshService = viewModel.meshServiceFacade,
|
||||
modifier = Modifier.weight(1f),
|
||||
conversationKey = "dm:$peerID",
|
||||
forceScrollToBottom = forceScrollToBottom,
|
||||
onScrolledUpChanged = { isUp -> isScrolledUp = isUp },
|
||||
onNicknameClick = { /* handle mention */ },
|
||||
@ -866,9 +900,8 @@ fun PrivateChatSheet(
|
||||
onImageClick = { _, _, _ -> /* handle image click */ }
|
||||
)
|
||||
|
||||
HorizontalDivider(color = colorScheme.outline.copy(alpha = 0.3f))
|
||||
|
||||
// Input section
|
||||
// Input section. No divider here: ChatInputSection draws its own fade and
|
||||
// hairline.
|
||||
var messageText by remember {
|
||||
mutableStateOf(
|
||||
androidx.compose.ui.text.input.TextFieldValue(
|
||||
@ -913,112 +946,97 @@ fun PrivateChatSheet(
|
||||
)
|
||||
}
|
||||
|
||||
// TopBar (fixed at top, iOS-style)
|
||||
BitchatSheetCenterTopBar(
|
||||
onClose = onDismiss,
|
||||
// Header. Built from the same tokens as the main chat header rather than a
|
||||
// TopAppBar, so moving between the timeline and a conversation does not shift the
|
||||
// bar's height, insets or type.
|
||||
Surface(
|
||||
modifier = Modifier.align(Alignment.TopCenter),
|
||||
navigationIcon = {
|
||||
IconButton(
|
||||
onClick = onDismiss,
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterStart)
|
||||
.padding(start = 16.dp)
|
||||
.size(32.dp)
|
||||
color = colorScheme.background
|
||||
) {
|
||||
ConversationHeader(
|
||||
leadingIconRes = conversationTransportIcon(
|
||||
isReachedOverInternet = isNostrPeer || isNostrReachableFavorite,
|
||||
isWifiAware = isWifiAware,
|
||||
isDirect = isDirect
|
||||
),
|
||||
leadingIconTint = colorScheme.primary,
|
||||
leadingContentDescription = when {
|
||||
isNostrPeer || isNostrReachableFavorite ->
|
||||
stringResource(R.string.cd_nostr_reachable)
|
||||
else -> null
|
||||
},
|
||||
title = titleText
|
||||
) {
|
||||
ConversationHeaderAction(
|
||||
onClick = { viewModel.toggleFavorite(peerID) },
|
||||
contentDescription = if (isFavorite) {
|
||||
stringResource(R.string.cd_remove_favorite)
|
||||
} else {
|
||||
stringResource(R.string.cd_add_favorite)
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.chat_back),
|
||||
tint = colorScheme.onSurface
|
||||
painter = painterResource(
|
||||
if (isFavorite) {
|
||||
R.drawable.ic_spec_star_filled
|
||||
} else {
|
||||
R.drawable.ic_spec_star
|
||||
}
|
||||
),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(HeaderIconSize),
|
||||
tint = if (isFavorite) {
|
||||
palette.accentOrange
|
||||
} else {
|
||||
colorScheme.onSurfaceVariant
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
title = {
|
||||
// Center content: connection status + name + encryption
|
||||
Row(
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
when {
|
||||
isNostrPeer || isNostrReachableFavorite -> {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Public,
|
||||
contentDescription = stringResource(R.string.cd_nostr_reachable),
|
||||
modifier = Modifier.size(14.dp),
|
||||
tint = Color(0xFF9C27B0)
|
||||
)
|
||||
}
|
||||
isWifiAware -> {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Wifi,
|
||||
contentDescription = "Direct Wi-Fi Aware",
|
||||
modifier = Modifier.size(14.dp),
|
||||
tint = colorScheme.onSurface.copy(alpha = 0.6f)
|
||||
)
|
||||
}
|
||||
isDirect -> {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Bluetooth,
|
||||
contentDescription = "Direct Bluetooth",
|
||||
modifier = Modifier.size(14.dp),
|
||||
tint = colorScheme.onSurface.copy(alpha = 0.6f)
|
||||
)
|
||||
}
|
||||
isConnected -> {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Route,
|
||||
contentDescription = "Routed",
|
||||
modifier = Modifier.size(14.dp),
|
||||
tint = colorScheme.onSurface.copy(alpha = 0.6f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = titleText,
|
||||
style = MaterialTheme.typography.titleMedium.copy(
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontFamily = FontFamily.Monospace
|
||||
),
|
||||
color = if (isNostrPeer || isNostrReachableFavorite) Color(0xFFFF9500) else colorScheme.onSurface
|
||||
)
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.then(securityModifier)
|
||||
// Encryption state, and the verification badge that qualifies it. Both are
|
||||
// read-only for Nostr peers, which have no Noise session at all.
|
||||
if (!isNostrPeer && !isNostrReachableFavorite) {
|
||||
ConversationHeaderAction(
|
||||
onClick = { viewModel.showSecurityVerificationSheet() },
|
||||
contentDescription = stringResource(R.string.verify_title)
|
||||
) {
|
||||
if (!isNostrPeer && !isNostrReachableFavorite) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
NoiseSessionIcon(
|
||||
sessionState = sessionState,
|
||||
modifier = Modifier.size(14.dp)
|
||||
)
|
||||
}
|
||||
|
||||
if (isVerified) {
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Verified,
|
||||
contentDescription = stringResource(R.string.verify_title),
|
||||
modifier = Modifier.size(14.dp),
|
||||
tint = Color(0xFF32D74B) // iOS Green
|
||||
modifier = Modifier.size(HeaderIconSize)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
IconButton(
|
||||
onClick = { viewModel.toggleFavorite(peerID) },
|
||||
modifier = Modifier.size(28.dp)
|
||||
if (isVerified) {
|
||||
Box(
|
||||
modifier = Modifier.size(HeaderIconSize),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (isFavorite) Icons.Filled.Star else Icons.Outlined.Star,
|
||||
contentDescription = if (isFavorite) stringResource(R.string.cd_remove_favorite) else stringResource(R.string.cd_add_favorite),
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = if (isFavorite) Color(0xFFFFD700) else colorScheme.onSurface.copy(alpha = 0.6f)
|
||||
painter = painterResource(R.drawable.ic_spec_check),
|
||||
contentDescription = stringResource(R.string.verify_title),
|
||||
modifier = Modifier.size(HeaderIconSize),
|
||||
tint = colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val dismiss = LocalSheetDismiss.current
|
||||
ConversationHeaderAction(
|
||||
onClick = { dismiss?.invoke() ?: onDismiss() },
|
||||
contentDescription = stringResource(R.string.close_plain)
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_spec_close),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(HeaderIconSize),
|
||||
tint = colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,18 @@
|
||||
package com.bitchat.android.ui
|
||||
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.core.AnimationSpec
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.FiniteAnimationSpec
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.VisibilityThreshold
|
||||
import androidx.compose.animation.core.spring
|
||||
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.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
@ -9,16 +21,16 @@ import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.calculateEndPadding
|
||||
import androidx.compose.foundation.layout.calculateStartPadding
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material3.ColorScheme
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@ -29,20 +41,25 @@ import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.bitchat.android.ui.theme.BitchatFontFamily
|
||||
import com.bitchat.android.R
|
||||
import com.bitchat.android.core.ui.component.text.AnnotatedClickableText
|
||||
import com.bitchat.android.mesh.MeshService
|
||||
@ -50,6 +67,13 @@ import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.model.BitchatMessageType
|
||||
import com.bitchat.android.model.DeliveryStatus
|
||||
import com.bitchat.android.ui.media.FileMessageItem
|
||||
import com.bitchat.android.ui.theme.BASE_FONT_SIZE
|
||||
import com.bitchat.android.ui.theme.BitchatMotion
|
||||
import com.bitchat.android.ui.theme.ChatVisualTokens
|
||||
import com.bitchat.android.ui.theme.LocalBitchatPalette
|
||||
import com.bitchat.android.ui.theme.MessageBodyTextStyle
|
||||
import com.bitchat.android.ui.theme.MessageSenderTextStyle
|
||||
import kotlinx.coroutines.delay
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
|
||||
@ -61,12 +85,119 @@ import java.util.Locale
|
||||
* Extracted from ChatScreen.kt for better organization
|
||||
*/
|
||||
|
||||
/** How far a newly arrived message travels up into place. */
|
||||
private val MessageEntrySlide = 14.dp
|
||||
|
||||
/**
|
||||
* Entry motion for a new message: quick, with just enough damping to settle rather than snap.
|
||||
* Runs entirely on a graphics layer, so it costs a transform and nothing else.
|
||||
*/
|
||||
private val MessageEntrySpec: AnimationSpec<Float> =
|
||||
spring(dampingRatio = 0.85f, stiffness = 1200f)
|
||||
|
||||
/**
|
||||
* Motion for messages being pushed out of the way by an arrival. Softer than the entry so the
|
||||
* conversation glides up while the new message itself lands crisply.
|
||||
*/
|
||||
private val MessagePlacementSpec: FiniteAnimationSpec<IntOffset> = spring(
|
||||
dampingRatio = Spring.DampingRatioNoBouncy,
|
||||
stiffness = Spring.StiffnessMediumLow,
|
||||
visibilityThreshold = IntOffset.VisibilityThreshold
|
||||
)
|
||||
|
||||
/** Removals are not worth dwelling on. */
|
||||
private val MessageFadeOutSpec: FiniteAnimationSpec<Float> = tween(BitchatMotion.QUICK_MS)
|
||||
|
||||
/**
|
||||
* How long placement animation stays armed after the list gains or loses a message.
|
||||
*
|
||||
* Comfortably longer than [MessagePlacementSpec] takes to settle, so an arrival's push is never cut
|
||||
* short.
|
||||
*/
|
||||
private const val PlacementArmWindowMs = 600L
|
||||
|
||||
/**
|
||||
* Above this many simultaneous arrivals, entry animations are skipped.
|
||||
*
|
||||
* A history sync or a channel switch can append hundreds of messages in one frame. Animating each
|
||||
* would spend the entire frame budget on motion nobody asked to see, so a burst is adopted
|
||||
* silently and only conversational-pace arrivals animate.
|
||||
*/
|
||||
internal const val MaxAnimatedArrivals = 6
|
||||
|
||||
/**
|
||||
* Remembers which message ids have already been seen, so genuine arrivals can be told apart from
|
||||
* items merely scrolling back into view.
|
||||
*
|
||||
* This distinction is the whole reason the entry animation is usable: `LazyColumn` composes items
|
||||
* on demand, so animating on first composition would replay the animation for every old message
|
||||
* the user scrolled back to.
|
||||
*/
|
||||
internal class MessageArrivalTracker {
|
||||
val known = HashSet<String>()
|
||||
var seeded = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Ids that should animate in on this composition pass.
|
||||
*
|
||||
* Deliberately computed during composition rather than in a `LaunchedEffect`: effects run *after*
|
||||
* the frame's composition, by which point a new message's item has already composed and would
|
||||
* have missed its cue.
|
||||
*/
|
||||
internal fun MessageArrivalTracker.arrivals(messages: List<BitchatMessage>): Set<String> {
|
||||
if (!seeded) {
|
||||
// First load adopts everything silently. A whole screenful animating on open reads as a
|
||||
// glitch, not a flourish.
|
||||
messages.forEach { known.add(it.id) }
|
||||
seeded = true
|
||||
return emptySet()
|
||||
}
|
||||
|
||||
// A list with nothing in common with the last one is a different conversation, not a burst of
|
||||
// arrivals — /clear, or a switch the caller did not give us a distinct key for. Adopt it
|
||||
// silently rather than sliding in every message at once.
|
||||
val isWholesaleReplacement =
|
||||
messages.isNotEmpty() && known.isNotEmpty() && messages.none { it.id in known }
|
||||
|
||||
// `HashSet.add` reports whether the id was new, so this both diffs and updates in one pass.
|
||||
val added = messages.filter { known.add(it.id) }
|
||||
|
||||
if (known.size > messages.size) {
|
||||
// Messages disappeared (/clear, channel switch). Drop the stale ids so the set cannot
|
||||
// grow without bound and so re-added messages animate again.
|
||||
known.retainAll(messages.mapTo(HashSet(messages.size)) { it.id })
|
||||
}
|
||||
|
||||
return when {
|
||||
isWholesaleReplacement -> emptySet()
|
||||
added.isEmpty() || added.size > MaxAnimatedArrivals -> emptySet()
|
||||
else -> added.mapTo(HashSet(added.size)) { it.id }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MessagesList(
|
||||
messages: List<BitchatMessage>,
|
||||
currentUserNickname: String,
|
||||
meshService: MeshService,
|
||||
modifier: Modifier = Modifier,
|
||||
mentionPeerIdentities: Map<String, PeerIdentity>? = null,
|
||||
/**
|
||||
* Extra inset on top of the list's own gutters.
|
||||
*
|
||||
* The chat screen's bars are translucent and the list scrolls underneath them, so the caller
|
||||
* has to reserve room for their heights here rather than by shrinking the viewport.
|
||||
*/
|
||||
contentPadding: PaddingValues = PaddingValues(0.dp),
|
||||
/**
|
||||
* Identity of the conversation being shown — a channel, a geohash, a peer.
|
||||
*
|
||||
* Everything below that is per-conversation state is keyed on this. Without it, switching
|
||||
* channels reused the previous conversation's scroll offset, follow flag and seen-message set,
|
||||
* so the new channel opened at a stale position and then animated itself into place.
|
||||
*/
|
||||
conversationKey: Any? = null,
|
||||
forceScrollToBottom: Boolean = false,
|
||||
onScrolledUpChanged: ((Boolean) -> Unit)? = null,
|
||||
onNicknameClick: ((String) -> Unit)? = null,
|
||||
@ -74,11 +205,23 @@ fun MessagesList(
|
||||
onCancelTransfer: ((BitchatMessage) -> Unit)? = null,
|
||||
onImageClick: ((String, List<String>, Int) -> Unit)? = null
|
||||
) {
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
val resolvedMentionPeerIdentities = remember(messages, mentionPeerIdentities) {
|
||||
mentionPeerIdentities ?: buildMentionPeerIdentityMap(messages)
|
||||
}
|
||||
|
||||
// A fresh scroll position per conversation. Sharing one state meant a switch inherited the
|
||||
// previous channel's offset and then had to correct itself, which is what the jump was.
|
||||
//
|
||||
// Passing the key as an *input* rather than as `key =` is deliberate: it discards the saved
|
||||
// offset on every switch, so a conversation always opens on its newest message instead of
|
||||
// wherever the reader happened to be some time ago, with unseen messages below them.
|
||||
val listState = rememberSaveable(conversationKey, saver = LazyListState.Saver) {
|
||||
LazyListState()
|
||||
}
|
||||
|
||||
// Track if this is the first time messages are being loaded
|
||||
var hasScrolledToInitialPosition by remember { mutableStateOf(false) }
|
||||
var followIncomingMessages by remember { mutableStateOf(true) }
|
||||
var hasScrolledToInitialPosition by remember(conversationKey) { mutableStateOf(false) }
|
||||
var followIncomingMessages by remember(conversationKey) { mutableStateOf(true) }
|
||||
|
||||
// Smart scroll: auto-scroll to bottom for initial load, then follow unless user scrolls away
|
||||
LaunchedEffect(messages.size) {
|
||||
@ -94,7 +237,7 @@ fun MessagesList(
|
||||
}
|
||||
|
||||
// Track whether user has scrolled away from the latest messages
|
||||
val isAtLatest by remember {
|
||||
val isAtLatest by remember(listState) {
|
||||
derivedStateOf {
|
||||
val firstVisibleIndex = listState.layoutInfo.visibleItemsInfo.firstOrNull()?.index ?: -1
|
||||
firstVisibleIndex <= 2
|
||||
@ -114,27 +257,107 @@ fun MessagesList(
|
||||
}
|
||||
}
|
||||
|
||||
// Recomputed only when the list actually gains or loses a message, and synchronously, so the
|
||||
// arriving item can read its cue during the same composition pass in which it first appears.
|
||||
// Reset per conversation, so a switch adopts the incoming messages silently instead of
|
||||
// treating a whole channel's backlog as brand-new arrivals and sliding each one in.
|
||||
val arrivalTracker = remember(conversationKey) { MessageArrivalTracker() }
|
||||
val enteringIds = remember(conversationKey, messages.size, messages.lastOrNull()?.id) {
|
||||
arrivalTracker.arrivals(messages)
|
||||
}
|
||||
|
||||
// Placement animation exists to soften insertions and removals. But *any* relayout moves every
|
||||
// item — the keyboard opening behind a bottom sheet, that sheet closing again, the composer
|
||||
// growing a line — and animating those made the whole conversation lurch. So it is armed only
|
||||
// briefly around a genuine change to the list, and is otherwise off, letting items track the
|
||||
// viewport exactly.
|
||||
var placementArmed by remember(conversationKey) { mutableStateOf(false) }
|
||||
var previousMessageCount by remember(conversationKey) { mutableStateOf<Int?>(null) }
|
||||
LaunchedEffect(conversationKey, messages.size) {
|
||||
val previous = previousMessageCount
|
||||
previousMessageCount = messages.size
|
||||
// Skip the first composition: the list settling into its initial padding is not a change
|
||||
// worth animating.
|
||||
if (previous == null || previous == messages.size) return@LaunchedEffect
|
||||
placementArmed = true
|
||||
delay(PlacementArmWindowMs)
|
||||
placementArmed = false
|
||||
}
|
||||
|
||||
val layoutDirection = LocalLayoutDirection.current
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
// Wider side gutters than the old 12.dp: the redesign trades a little line length for
|
||||
// a much calmer edge, and long monospace lines were running into the screen bezel.
|
||||
contentPadding = PaddingValues(
|
||||
start = 16.dp + contentPadding.calculateStartPadding(layoutDirection),
|
||||
end = 16.dp + contentPadding.calculateEndPadding(layoutDirection),
|
||||
top = 8.dp + contentPadding.calculateTopPadding(),
|
||||
bottom = 12.dp + contentPadding.calculateBottomPadding()
|
||||
),
|
||||
// Spacing is owned by each item. The exported transcript uses a consistent 8.dp rhythm;
|
||||
// a new speaker gets additional separation from the visible sender row's top inset.
|
||||
verticalArrangement = Arrangement.spacedBy(0.dp),
|
||||
modifier = modifier,
|
||||
reverseLayout = true
|
||||
) {
|
||||
items(
|
||||
items = messages.asReversed(),
|
||||
key = { it.id }
|
||||
) { message ->
|
||||
MessageItem(
|
||||
message = message,
|
||||
messages = messages,
|
||||
currentUserNickname = currentUserNickname,
|
||||
meshService = meshService,
|
||||
onNicknameClick = onNicknameClick,
|
||||
onMessageLongPress = onMessageLongPress,
|
||||
onCancelTransfer = onCancelTransfer,
|
||||
onImageClick = onImageClick
|
||||
)
|
||||
val reversed = messages.asReversed()
|
||||
itemsIndexed(
|
||||
items = reversed,
|
||||
key = { _, message -> message.id }
|
||||
) { reversedIndex, message ->
|
||||
// reverseLayout renders index 0 at the bottom, so the chronological predecessor of
|
||||
// this row lives at a *higher* original index offset. Resolve against the original
|
||||
// list rather than the reversed view to keep the grouping logic readable.
|
||||
val originalIndex = messages.lastIndex - reversedIndex
|
||||
val previous = messages.getOrNull(originalIndex - 1)
|
||||
val isGrouped = MessageGrouping.shouldGroup(previous, message)
|
||||
|
||||
// Decided once per item instance, so an item recycling back into view during a scroll
|
||||
// never re-animates. Items that are not arriving skip the animation machinery
|
||||
// entirely: no Animatable, no coroutine, and no extra render layer per row.
|
||||
val isArriving = remember(message.id) { message.id in enteringIds }
|
||||
val entryModifier = if (isArriving) {
|
||||
val entry = remember(message.id) { Animatable(0f) }
|
||||
LaunchedEffect(message.id) { entry.animateTo(1f, MessageEntrySpec) }
|
||||
// A draw-time transform only: no measure, no layout, and no recomposition of the
|
||||
// message content on any frame of the animation.
|
||||
Modifier.graphicsLayer {
|
||||
val progress = entry.value
|
||||
alpha = progress
|
||||
translationY = (1f - progress) * MessageEntrySlide.toPx()
|
||||
}
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
|
||||
MessageItem(
|
||||
message = message,
|
||||
messages = messages,
|
||||
currentUserNickname = currentUserNickname,
|
||||
meshService = meshService,
|
||||
mentionPeerIdentities = resolvedMentionPeerIdentities,
|
||||
showSender = !isGrouped,
|
||||
topSpacing = MessageGrouping.topSpacingFor(
|
||||
isGrouped = isGrouped,
|
||||
isFirstInList = originalIndex == 0
|
||||
),
|
||||
onNicknameClick = onNicknameClick,
|
||||
onMessageLongPress = onMessageLongPress,
|
||||
onCancelTransfer = onCancelTransfer,
|
||||
onImageClick = onImageClick,
|
||||
modifier = Modifier
|
||||
// Animates the shift when a neighbour is inserted or removed: this is what
|
||||
// makes the conversation glide up instead of jumping.
|
||||
.animateItem(
|
||||
// Entry fade is handled by entryModifier, together with the slide, so the
|
||||
// two cannot drift out of step.
|
||||
fadeInSpec = null,
|
||||
placementSpec = if (placementArmed) MessagePlacementSpec else null,
|
||||
fadeOutSpec = MessageFadeOutSpec
|
||||
)
|
||||
.then(entryModifier)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -146,16 +369,22 @@ fun MessageItem(
|
||||
currentUserNickname: String,
|
||||
meshService: MeshService,
|
||||
messages: List<BitchatMessage> = emptyList(),
|
||||
mentionPeerIdentities: Map<String, PeerIdentity> = emptyMap(),
|
||||
showSender: Boolean = true,
|
||||
topSpacing: Dp = 0.dp,
|
||||
onNicknameClick: ((String) -> Unit)? = null,
|
||||
onMessageLongPress: ((BitchatMessage) -> Unit)? = null,
|
||||
onCancelTransfer: ((BitchatMessage) -> Unit)? = null,
|
||||
onImageClick: ((String, List<String>, Int) -> Unit)? = null
|
||||
onImageClick: ((String, List<String>, Int) -> Unit)? = null,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val timeFormatter = remember { SimpleDateFormat("HH:mm:ss", Locale.getDefault()) }
|
||||
|
||||
val timeFormatter = remember { SimpleDateFormat(CHAT_TIMESTAMP_PATTERN, Locale.getDefault()) }
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = topSpacing),
|
||||
verticalArrangement = Arrangement.spacedBy(0.dp)
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxWidth()) {
|
||||
@ -172,8 +401,10 @@ fun MessageItem(
|
||||
messages = messages,
|
||||
currentUserNickname = currentUserNickname,
|
||||
meshService = meshService,
|
||||
mentionPeerIdentities = mentionPeerIdentities,
|
||||
colorScheme = colorScheme,
|
||||
timeFormatter = timeFormatter,
|
||||
showSender = showSender,
|
||||
onNicknameClick = onNicknameClick,
|
||||
onMessageLongPress = onMessageLongPress,
|
||||
onCancelTransfer = onCancelTransfer,
|
||||
@ -209,14 +440,18 @@ fun MessageItem(
|
||||
messages: List<BitchatMessage>,
|
||||
currentUserNickname: String,
|
||||
meshService: MeshService,
|
||||
mentionPeerIdentities: Map<String, PeerIdentity>,
|
||||
colorScheme: ColorScheme,
|
||||
timeFormatter: SimpleDateFormat,
|
||||
showSender: Boolean,
|
||||
onNicknameClick: ((String) -> Unit)?,
|
||||
onMessageLongPress: ((BitchatMessage) -> Unit)?,
|
||||
onCancelTransfer: ((BitchatMessage) -> Unit)?,
|
||||
onImageClick: ((String, List<String>, Int) -> Unit)?,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val palette = LocalBitchatPalette.current
|
||||
|
||||
// Image special rendering
|
||||
if (message.type == BitchatMessageType.Image) {
|
||||
com.bitchat.android.ui.media.ImageMessageItem(
|
||||
@ -226,6 +461,7 @@ fun MessageItem(
|
||||
meshService = meshService,
|
||||
colorScheme = colorScheme,
|
||||
timeFormatter = timeFormatter,
|
||||
showSender = showSender,
|
||||
onNicknameClick = onNicknameClick,
|
||||
onMessageLongPress = onMessageLongPress,
|
||||
onCancelTransfer = onCancelTransfer,
|
||||
@ -243,6 +479,7 @@ fun MessageItem(
|
||||
meshService = meshService,
|
||||
colorScheme = colorScheme,
|
||||
timeFormatter = timeFormatter,
|
||||
showSender = showSender,
|
||||
onNicknameClick = onNicknameClick,
|
||||
onMessageLongPress = onMessageLongPress,
|
||||
onCancelTransfer = onCancelTransfer,
|
||||
@ -268,9 +505,11 @@ fun MessageItem(
|
||||
val headerText = formatMessageHeaderAnnotatedString(
|
||||
message = message,
|
||||
currentUserNickname = currentUserNickname,
|
||||
meshService = meshService,
|
||||
colorScheme = colorScheme,
|
||||
timeFormatter = timeFormatter
|
||||
myPeerID = meshService.myPeerID,
|
||||
palette = palette,
|
||||
contentColor = colorScheme.onSurface,
|
||||
timeFormatter = timeFormatter,
|
||||
includeSender = showSender
|
||||
)
|
||||
val haptic = LocalHapticFeedback.current
|
||||
AnnotatedClickableText(
|
||||
@ -286,7 +525,7 @@ fun MessageItem(
|
||||
}
|
||||
},
|
||||
onLongPress = { onMessageLongPress?.invoke(message) },
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
color = colorScheme.onSurface,
|
||||
)
|
||||
|
||||
@ -335,15 +574,24 @@ fun MessageItem(
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(4.dp)
|
||||
.size(22.dp)
|
||||
.background(Color.Gray.copy(alpha = 0.6f), CircleShape)
|
||||
.background(colorScheme.surfaceVariant.copy(alpha = 0.85f), CircleShape)
|
||||
.clickable { onCancelTransfer?.invoke(message) },
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(imageVector = Icons.Filled.Close, contentDescription = stringResource(R.string.cd_cancel), tint = Color.White, modifier = Modifier.size(14.dp))
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Close,
|
||||
contentDescription = stringResource(R.string.cd_cancel),
|
||||
tint = colorScheme.onSurface,
|
||||
modifier = Modifier.size(14.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Text(text = stringResource(R.string.file_unavailable), fontFamily = FontFamily.Monospace, color = Color.Gray)
|
||||
Text(
|
||||
text = stringResource(R.string.file_unavailable),
|
||||
fontFamily = BitchatFontFamily,
|
||||
color = palette.textTertiary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -351,31 +599,15 @@ fun MessageItem(
|
||||
return
|
||||
}
|
||||
|
||||
// Check if this message should be animated during PoW mining
|
||||
val shouldAnimate = shouldAnimateMessage(message.id)
|
||||
|
||||
// If animation is needed, use the matrix animation component for content only
|
||||
if (shouldAnimate) {
|
||||
// Display message with matrix animation for content
|
||||
MessageWithMatrixAnimation(
|
||||
message = message,
|
||||
currentUserNickname = currentUserNickname,
|
||||
meshService = meshService,
|
||||
colorScheme = colorScheme,
|
||||
timeFormatter = timeFormatter,
|
||||
onNicknameClick = onNicknameClick,
|
||||
onMessageLongPress = onMessageLongPress,
|
||||
modifier = modifier
|
||||
)
|
||||
} else if (message.sender == "system") {
|
||||
// Keep system messages on the compact legacy line.
|
||||
val annotatedText = formatMessageAsAnnotatedString(
|
||||
message = message,
|
||||
currentUserNickname = currentUserNickname,
|
||||
meshService = meshService,
|
||||
colorScheme = colorScheme,
|
||||
timeFormatter = timeFormatter
|
||||
)
|
||||
if (message.sender == "system") {
|
||||
// Background narration: `// Tor started. Routing all chats…`
|
||||
val annotatedText = remember(message, colorScheme.onSurface) {
|
||||
formatSystemMessage(
|
||||
message = message,
|
||||
contentColor = colorScheme.onSurface,
|
||||
timeFormatter = timeFormatter
|
||||
)
|
||||
}
|
||||
|
||||
val haptic = LocalHapticFeedback.current
|
||||
Text(
|
||||
@ -388,11 +620,11 @@ fun MessageItem(
|
||||
}
|
||||
)
|
||||
},
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
softWrap = true,
|
||||
overflow = TextOverflow.Visible,
|
||||
style = androidx.compose.ui.text.TextStyle(
|
||||
color = colorScheme.onSurface
|
||||
style = ChatVisualTokens.SystemActionStyle.copy(
|
||||
color = colorScheme.onSurface.copy(alpha = ChatVisualTokens.MutedTextAlpha),
|
||||
)
|
||||
)
|
||||
} else {
|
||||
@ -400,8 +632,10 @@ fun MessageItem(
|
||||
message = message,
|
||||
currentUserNickname = currentUserNickname,
|
||||
meshService = meshService,
|
||||
mentionPeerIdentities = mentionPeerIdentities,
|
||||
colorScheme = colorScheme,
|
||||
timeFormatter = timeFormatter,
|
||||
showSender = showSender,
|
||||
onNicknameClick = onNicknameClick,
|
||||
onMessageLongPress = onMessageLongPress,
|
||||
modifier = modifier,
|
||||
@ -414,37 +648,47 @@ internal fun TextMessageLayout(
|
||||
message: BitchatMessage,
|
||||
currentUserNickname: String,
|
||||
meshService: MeshService,
|
||||
mentionPeerIdentities: Map<String, PeerIdentity> = emptyMap(),
|
||||
colorScheme: ColorScheme,
|
||||
timeFormatter: SimpleDateFormat,
|
||||
onNicknameClick: ((String) -> Unit)?,
|
||||
onMessageLongPress: ((BitchatMessage) -> Unit)?,
|
||||
modifier: Modifier = Modifier,
|
||||
showSender: Boolean = true,
|
||||
bodyContent: String = message.content,
|
||||
) {
|
||||
val palette = LocalBitchatPalette.current
|
||||
val myPeerId = meshService.myPeerID
|
||||
val displayMessage = remember(message, bodyContent) {
|
||||
if (bodyContent == message.content) message else message.copy(content = bodyContent)
|
||||
}
|
||||
val senderText = remember(message, currentUserNickname, myPeerId, colorScheme) {
|
||||
val senderText = remember(message, currentUserNickname, myPeerId, palette) {
|
||||
formatTextMessageSender(
|
||||
message = message,
|
||||
currentUserNickname = currentUserNickname,
|
||||
meshService = meshService,
|
||||
colorScheme = colorScheme,
|
||||
myPeerID = myPeerId,
|
||||
palette = palette,
|
||||
)
|
||||
}
|
||||
val metadataText = remember(message.timestamp, message.powDifficulty, timeFormatter) {
|
||||
formatTextMessageMetadata(
|
||||
message = message,
|
||||
timeFormatter = timeFormatter,
|
||||
)
|
||||
}
|
||||
val bodyText = remember(displayMessage, currentUserNickname, myPeerId, colorScheme) {
|
||||
// The timestamp trails the body rather than occupying its own column, so a short message
|
||||
// no longer reserves a full-width row for eight grey characters.
|
||||
val bodyText = remember(
|
||||
displayMessage,
|
||||
currentUserNickname,
|
||||
palette,
|
||||
colorScheme.onSurface,
|
||||
colorScheme.secondary,
|
||||
mentionPeerIdentities,
|
||||
timeFormatter
|
||||
) {
|
||||
formatTextMessageBody(
|
||||
message = displayMessage,
|
||||
currentUserNickname = currentUserNickname,
|
||||
meshService = meshService,
|
||||
colorScheme = colorScheme,
|
||||
palette = palette,
|
||||
contentColor = colorScheme.onSurface,
|
||||
linkColor = colorScheme.secondary,
|
||||
mentionPeerIdentities = mentionPeerIdentities,
|
||||
timeFormatter = timeFormatter,
|
||||
)
|
||||
}
|
||||
val isSelf = message.isFromSelf(currentUserNickname, myPeerId)
|
||||
@ -457,12 +701,9 @@ internal fun TextMessageLayout(
|
||||
|
||||
Column(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(MessageGrouping.SENDER_TO_BODY_SPACING),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (showSender) {
|
||||
AnnotatedClickableText(
|
||||
text = senderText,
|
||||
annotationTags = listOf("nickname_click"),
|
||||
@ -476,18 +717,13 @@ internal fun TextMessageLayout(
|
||||
}
|
||||
},
|
||||
onLongPress = handleLongPress,
|
||||
modifier = Modifier.weight(1f),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = MessageGrouping.SENDER_TOP_PADDING),
|
||||
fontFamily = BitchatFontFamily,
|
||||
softWrap = false,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
AnnotatedClickableText(
|
||||
text = metadataText,
|
||||
annotationTags = emptyList(),
|
||||
onAnnotationClick = { _, _ -> false },
|
||||
onLongPress = handleLongPress,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
softWrap = false,
|
||||
style = MessageSenderTextStyle,
|
||||
)
|
||||
}
|
||||
|
||||
@ -512,10 +748,10 @@ internal fun TextMessageLayout(
|
||||
}
|
||||
},
|
||||
onLongPress = handleLongPress,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
softWrap = true,
|
||||
overflow = TextOverflow.Visible,
|
||||
style = androidx.compose.ui.text.TextStyle(color = colorScheme.onSurface),
|
||||
style = MessageBodyTextStyle.copy(color = colorScheme.onSurface),
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -523,53 +759,40 @@ internal fun TextMessageLayout(
|
||||
@Composable
|
||||
fun DeliveryStatusIcon(status: DeliveryStatus) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
|
||||
when (status) {
|
||||
is DeliveryStatus.Sending -> {
|
||||
Text(
|
||||
text = stringResource(R.string.status_sending),
|
||||
fontSize = 10.sp,
|
||||
color = colorScheme.primary.copy(alpha = 0.6f)
|
||||
)
|
||||
}
|
||||
is DeliveryStatus.Sent -> {
|
||||
// Use a subtle hollow marker for Sent; single check is reserved for Delivered (iOS parity)
|
||||
Text(
|
||||
text = stringResource(R.string.status_pending),
|
||||
fontSize = 10.sp,
|
||||
color = colorScheme.primary.copy(alpha = 0.6f)
|
||||
)
|
||||
}
|
||||
is DeliveryStatus.Delivered -> {
|
||||
// Single check for Delivered (matches iOS expectations)
|
||||
Text(
|
||||
text = stringResource(R.string.status_sent),
|
||||
fontSize = 10.sp,
|
||||
color = colorScheme.primary.copy(alpha = 0.8f)
|
||||
)
|
||||
}
|
||||
is DeliveryStatus.Read -> {
|
||||
Text(
|
||||
text = stringResource(R.string.status_delivered),
|
||||
fontSize = 10.sp,
|
||||
color = Color(0xFF007AFF), // Blue
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
is DeliveryStatus.Failed -> {
|
||||
Text(
|
||||
text = stringResource(R.string.status_failed),
|
||||
fontSize = 10.sp,
|
||||
color = Color.Red.copy(alpha = 0.8f)
|
||||
)
|
||||
}
|
||||
is DeliveryStatus.PartiallyDelivered -> {
|
||||
// Show a single subdued check without numeric label
|
||||
Text(
|
||||
text = stringResource(R.string.status_sent),
|
||||
fontSize = 10.sp,
|
||||
color = colorScheme.primary.copy(alpha = 0.6f)
|
||||
)
|
||||
|
||||
// Status advances on its own as acks come back, so a hard glyph swap reads as a flicker.
|
||||
// Keyed on the status *type* rather than the instance, because Delivered/Read carry a
|
||||
// timestamp that would otherwise retrigger the transition on every identical update.
|
||||
AnimatedContent(
|
||||
targetState = status::class,
|
||||
transitionSpec = {
|
||||
fadeIn(tween(BitchatMotion.STANDARD_MS)) togetherWith
|
||||
fadeOut(tween(BitchatMotion.QUICK_MS))
|
||||
},
|
||||
label = "deliveryStatus"
|
||||
) { statusClass ->
|
||||
val (text, color, weight) = when (statusClass) {
|
||||
DeliveryStatus.Sending::class ->
|
||||
Triple(R.string.status_sending, colorScheme.primary.copy(alpha = 0.6f), FontWeight.Normal)
|
||||
// Subtle hollow marker for Sent; a single check is reserved for Delivered (iOS parity).
|
||||
DeliveryStatus.Sent::class ->
|
||||
Triple(R.string.status_pending, colorScheme.primary.copy(alpha = 0.6f), FontWeight.Normal)
|
||||
DeliveryStatus.Delivered::class ->
|
||||
Triple(R.string.status_sent, colorScheme.primary.copy(alpha = 0.8f), FontWeight.Normal)
|
||||
DeliveryStatus.Read::class ->
|
||||
Triple(R.string.status_delivered, colorScheme.secondary, FontWeight.Bold)
|
||||
DeliveryStatus.Failed::class ->
|
||||
Triple(R.string.status_failed, colorScheme.error, FontWeight.Normal)
|
||||
// A single subdued check, without the numeric label.
|
||||
else ->
|
||||
Triple(R.string.status_sent, colorScheme.primary.copy(alpha = 0.6f), FontWeight.Normal)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = stringResource(text),
|
||||
fontSize = 10.sp,
|
||||
color = color,
|
||||
fontWeight = weight
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
84
app/src/main/java/com/bitchat/android/ui/MessageGrouping.kt
Normal file
@ -0,0 +1,84 @@
|
||||
package com.bitchat.android.ui
|
||||
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.ui.theme.ChatVisualTokens
|
||||
|
||||
/**
|
||||
* Consecutive-message grouping for the chat surface.
|
||||
*
|
||||
* The redesign suppresses the `@sender` label on runs of messages from the same author so the
|
||||
* eye only has to register a name when the speaker actually changes. The transcript uses one
|
||||
* consistent item rhythm; new groups get their additional separation from the visible sender
|
||||
* row's own top padding.
|
||||
*/
|
||||
object MessageGrouping {
|
||||
|
||||
/** Space above the first message of a group (i.e. one that renders its sender label). */
|
||||
val NEW_GROUP_SPACING: Dp = ChatVisualTokens.MessageItemSpacing
|
||||
|
||||
/** Space above a continuation message inside an existing group. */
|
||||
val GROUPED_SPACING: Dp = ChatVisualTokens.MessageItemSpacing
|
||||
|
||||
/** Gap between the sender label and the first line of the body. */
|
||||
val SENDER_TO_BODY_SPACING: Dp = ChatVisualTokens.SenderToBodySpacing
|
||||
|
||||
/** Top inset inside a visible sender row. */
|
||||
val SENDER_TOP_PADDING: Dp = ChatVisualTokens.SenderTopPadding
|
||||
|
||||
/**
|
||||
* Messages further apart than this always start a new group, even from the same sender:
|
||||
* a reply an hour later is a new thought, not a continuation.
|
||||
*/
|
||||
const val GROUPING_WINDOW_MS: Long = 5 * 60 * 1000L
|
||||
|
||||
private const val SYSTEM_SENDER = "system"
|
||||
|
||||
/**
|
||||
* Whether [current] should be rendered as a continuation of [previous], hiding its sender
|
||||
* label.
|
||||
*
|
||||
* [previous] is the message immediately *before* [current] in chronological order. Note the
|
||||
* message list renders with `reverseLayout = true`, so callers must be careful to resolve
|
||||
* the chronological predecessor rather than the visually preceding item.
|
||||
*/
|
||||
fun shouldGroup(previous: BitchatMessage?, current: BitchatMessage): Boolean {
|
||||
if (previous == null) return false
|
||||
|
||||
// System/action lines never participate in grouping in either direction: they are
|
||||
// narration, and folding a real message into them would attribute it to "system".
|
||||
if (previous.sender == SYSTEM_SENDER || current.sender == SYSTEM_SENDER) return false
|
||||
|
||||
// Never group across the public/private boundary or between different channels.
|
||||
if (previous.isPrivate != current.isPrivate) return false
|
||||
if (previous.channel != current.channel) return false
|
||||
|
||||
if (!isSameSender(previous, current)) return false
|
||||
|
||||
val elapsed = current.timestamp.time - previous.timestamp.time
|
||||
return elapsed in 0..GROUPING_WINDOW_MS
|
||||
}
|
||||
|
||||
/**
|
||||
* Identity comparison. Peer IDs are authoritative when both messages carry one, because two
|
||||
* different peers can share a nickname. Falls back to the display name (which includes the
|
||||
* `#abcd` suffix) when peer IDs are unavailable, e.g. for locally injected messages.
|
||||
*/
|
||||
private fun isSameSender(previous: BitchatMessage, current: BitchatMessage): Boolean {
|
||||
val previousPeerID = previous.senderPeerID
|
||||
val currentPeerID = current.senderPeerID
|
||||
return if (previousPeerID != null && currentPeerID != null) {
|
||||
previousPeerID.equals(currentPeerID, ignoreCase = true)
|
||||
} else {
|
||||
previous.sender == current.sender
|
||||
}
|
||||
}
|
||||
|
||||
/** Top padding for a message given whether it continues the previous author's run. */
|
||||
fun topSpacingFor(isGrouped: Boolean, isFirstInList: Boolean): Dp = when {
|
||||
isFirstInList -> 0.dp
|
||||
isGrouped -> GROUPED_SPACING
|
||||
else -> NEW_GROUP_SPACING
|
||||
}
|
||||
}
|
||||
@ -3,7 +3,6 @@ package com.bitchat.android.ui
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.core.net.toUri
|
||||
import com.bitchat.android.geohash.ChannelID
|
||||
import com.bitchat.android.geohash.GeohashChannel
|
||||
import com.bitchat.android.geohash.GeohashChannelLevel
|
||||
import com.bitchat.android.geohash.LocationChannelManager
|
||||
@ -48,6 +47,5 @@ internal fun channelForGeohash(geohash: String): GeohashChannel {
|
||||
internal fun navigateToGeohash(context: Context, geohash: String): Boolean =
|
||||
runCatching {
|
||||
val locationManager = LocationChannelManager.getInstance(context)
|
||||
locationManager.setTeleported(true)
|
||||
locationManager.select(ChannelID.Location(channelForGeohash(geohash)))
|
||||
locationManager.selectManual(channelForGeohash(geohash))
|
||||
}.isSuccess
|
||||
|
||||
@ -138,7 +138,7 @@ class NotificationManager(
|
||||
*/
|
||||
fun setCurrentGeohash(geohash: String?) {
|
||||
currentGeohash = geohash
|
||||
Log.d(TAG, "Current geohash changed: $geohash")
|
||||
Log.d(TAG, "Current geohash changed")
|
||||
}
|
||||
|
||||
/**
|
||||
@ -446,11 +446,11 @@ class NotificationManager(
|
||||
val shouldNotify = isAppInBackground || (!isAppInBackground && currentGeohash != geohash)
|
||||
|
||||
if (!shouldNotify) {
|
||||
Log.d(TAG, "Skipping geohash notification - app in foreground and viewing geohash $geohash")
|
||||
Log.d(TAG, "Skipping geohash notification while viewing the channel")
|
||||
return
|
||||
}
|
||||
|
||||
Log.d(TAG, "Showing geohash notification for $geohash from $senderNickname (mention: $isMention, first: $isFirstMessage)")
|
||||
Log.d(TAG, "Showing geohash notification (mention: $isMention, first: $isFirstMessage)")
|
||||
|
||||
val notification = GeohashNotification(
|
||||
geohash = geohash,
|
||||
@ -652,7 +652,7 @@ class NotificationManager(
|
||||
showGeohashSummaryNotification()
|
||||
}
|
||||
|
||||
Log.d(TAG, "Cleared notifications for geohash: $geohash")
|
||||
Log.d(TAG, "Cleared notifications for geohash")
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
71
app/src/main/java/com/bitchat/android/ui/PeerIdentity.kt
Normal file
@ -0,0 +1,71 @@
|
||||
package com.bitchat.android.ui
|
||||
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Canonical, presentation-neutral identity used to derive a peer hue.
|
||||
*
|
||||
* Callers cannot construct arbitrary color seeds. Every identity is normalized and namespaced
|
||||
* here so the same mesh or Nostr user resolves to the same color on every surface.
|
||||
*/
|
||||
@JvmInline
|
||||
value class PeerIdentity private constructor(internal val stableKey: String) {
|
||||
companion object {
|
||||
fun mesh(peerID: String): PeerIdentity =
|
||||
PeerIdentity("noise:${normalize(peerID)}")
|
||||
|
||||
/**
|
||||
* Preserve the established geohash-chat color mapping while accepting a full public key.
|
||||
*
|
||||
* Older chat rendering hashed `nostr:nostr:<first 8 hex characters>`. Keeping that visual
|
||||
* key here avoids recoloring existing conversations; the important change is that every
|
||||
* surface now derives it from the same canonical Nostr identity.
|
||||
*/
|
||||
fun nostr(pubkeyHex: String): PeerIdentity {
|
||||
val normalized = normalizeNostrIdentifier(pubkeyHex)
|
||||
return PeerIdentity("nostr:nostr:${normalized.take(8)}")
|
||||
}
|
||||
|
||||
/**
|
||||
* Last-resort identity for legacy messages and mentions that carry no stable peer ID.
|
||||
*/
|
||||
fun nickname(nickname: String): PeerIdentity =
|
||||
PeerIdentity(normalize(nickname))
|
||||
|
||||
private fun normalize(value: String): String =
|
||||
value.trim().lowercase(Locale.ROOT)
|
||||
|
||||
private fun normalizeNostrIdentifier(value: String): String {
|
||||
var normalized = normalize(value)
|
||||
while (normalized.startsWith("nostr:") || normalized.startsWith("nostr_")) {
|
||||
normalized = normalized.removePrefix("nostr:").removePrefix("nostr_")
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the canonical identity attached to a rendered message.
|
||||
*
|
||||
* Full Nostr keys take precedence over routing aliases such as `nostr_abcd…`; aliases remain a
|
||||
* compatibility fallback for messages saved by older app versions.
|
||||
*/
|
||||
fun peerIdentityForMessage(message: BitchatMessage): PeerIdentity {
|
||||
message.senderNostrPubkey?.takeIf { it.isNotBlank() }?.let {
|
||||
return PeerIdentity.nostr(it)
|
||||
}
|
||||
|
||||
val senderPeerID = message.senderPeerID
|
||||
return when {
|
||||
senderPeerID?.startsWith("nostr:", ignoreCase = true) == true ||
|
||||
senderPeerID?.startsWith("nostr_", ignoreCase = true) == true -> {
|
||||
PeerIdentity.nostr(senderPeerID)
|
||||
}
|
||||
senderPeerID?.length == 16 || senderPeerID?.length == 64 -> {
|
||||
PeerIdentity.mesh(senderPeerID)
|
||||
}
|
||||
else -> PeerIdentity.nickname(message.sender)
|
||||
}
|
||||
}
|
||||
@ -1,133 +0,0 @@
|
||||
package com.bitchat.android.ui
|
||||
|
||||
import androidx.compose.animation.core.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Security
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.bitchat.android.nostr.NostrProofOfWork
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.bitchat.android.R
|
||||
import com.bitchat.android.nostr.PoWPreferenceManager
|
||||
|
||||
/**
|
||||
* Shows the current Proof of Work status and settings
|
||||
*/
|
||||
@Composable
|
||||
fun PoWStatusIndicator(
|
||||
modifier: Modifier = Modifier,
|
||||
style: PoWIndicatorStyle = PoWIndicatorStyle.COMPACT
|
||||
) {
|
||||
val powEnabled by PoWPreferenceManager.powEnabled.collectAsStateWithLifecycle()
|
||||
val powDifficulty by PoWPreferenceManager.powDifficulty.collectAsStateWithLifecycle()
|
||||
val isMining by PoWPreferenceManager.isMining.collectAsStateWithLifecycle()
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
|
||||
|
||||
if (!powEnabled) return
|
||||
|
||||
when (style) {
|
||||
PoWIndicatorStyle.COMPACT -> {
|
||||
Row(
|
||||
modifier = modifier,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// PoW icon with animation if mining
|
||||
if (isMining) {
|
||||
val rotation by rememberInfiniteTransition(label = "pow-rotation").animateFloat(
|
||||
initialValue = 0f,
|
||||
targetValue = 360f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(1000, easing = LinearEasing),
|
||||
repeatMode = RepeatMode.Restart
|
||||
),
|
||||
label = "pow-icon-rotation"
|
||||
)
|
||||
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Security,
|
||||
contentDescription = stringResource(R.string.cd_mining_pow),
|
||||
tint = Color(0xFFFF9500), // Orange for mining
|
||||
modifier = Modifier
|
||||
.size(12.dp)
|
||||
.graphicsLayer { rotationZ = rotation }
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Security,
|
||||
contentDescription = stringResource(R.string.cd_pow_enabled),
|
||||
tint = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D), // Green when ready
|
||||
modifier = Modifier.size(12.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
PoWIndicatorStyle.DETAILED -> {
|
||||
Surface(
|
||||
modifier = modifier,
|
||||
color = colorScheme.surfaceVariant.copy(alpha = 0.3f),
|
||||
shape = androidx.compose.foundation.shape.RoundedCornerShape(8.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// PoW icon
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Security,
|
||||
contentDescription = stringResource(R.string.cd_proof_of_work),
|
||||
tint = if (isMining) Color(0xFFFF9500) else {
|
||||
if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D)
|
||||
},
|
||||
modifier = Modifier.size(14.dp)
|
||||
)
|
||||
|
||||
// Status text
|
||||
Text(
|
||||
text = if (isMining) {
|
||||
stringResource(R.string.pow_mining_ellipsis)
|
||||
} else {
|
||||
stringResource(R.string.pow_label_format, powDifficulty)
|
||||
},
|
||||
fontSize = 11.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = if (isMining) Color(0xFFFF9500) else {
|
||||
colorScheme.onSurface.copy(alpha = 0.7f)
|
||||
}
|
||||
)
|
||||
|
||||
// Time estimate
|
||||
if (!isMining && powDifficulty > 0) {
|
||||
Text(
|
||||
text = stringResource(R.string.pow_time_estimate, NostrProofOfWork.estimateMiningTime(powDifficulty)),
|
||||
fontSize = 9.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = colorScheme.onSurface.copy(alpha = 0.5f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Style options for the PoW status indicator
|
||||
*/
|
||||
enum class PoWIndicatorStyle {
|
||||
COMPACT, // Small icon + difficulty number
|
||||
DETAILED // Icon + status text + time estimate
|
||||
}
|
||||
64
app/src/main/java/com/bitchat/android/ui/PressFeedback.kt
Normal file
@ -0,0 +1,64 @@
|
||||
package com.bitchat.android.ui
|
||||
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.InteractionSource
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.scale
|
||||
|
||||
/**
|
||||
* Press feedback for the app's icon buttons.
|
||||
*
|
||||
* Terminal-style chrome has no elevation and no fills to lean on, so a Material ripple has almost
|
||||
* nothing to show. A brief scale dip is legible on any background and reads as physical. Springs
|
||||
* rather than tweens, so releasing overshoots very slightly instead of stopping dead.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberPressScale(
|
||||
interactionSource: InteractionSource,
|
||||
pressedScale: Float = 0.86f
|
||||
): Float {
|
||||
val isPressed by interactionSource.collectIsPressedAsState()
|
||||
val scale by animateFloatAsState(
|
||||
targetValue = if (isPressed) pressedScale else 1f,
|
||||
animationSpec = spring(
|
||||
dampingRatio = Spring.DampingRatioMediumBouncy,
|
||||
stiffness = Spring.StiffnessHigh
|
||||
),
|
||||
label = "pressScale"
|
||||
)
|
||||
return scale
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience wrapper for the common case: a clickable that scales while held.
|
||||
*
|
||||
* Returns the modifier chain to apply, having disabled the default indication — the scale *is* the
|
||||
* indication, and a ripple underneath it just muddies the edges of these small glyphs.
|
||||
*/
|
||||
@Composable
|
||||
fun Modifier.pressScaleClickable(
|
||||
onClick: () -> Unit,
|
||||
enabled: Boolean = true,
|
||||
onClickLabel: String? = null,
|
||||
pressedScale: Float = 0.86f
|
||||
): Modifier {
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
val scale = rememberPressScale(interactionSource, pressedScale)
|
||||
return this
|
||||
.clickable(
|
||||
interactionSource = interactionSource,
|
||||
indication = null,
|
||||
enabled = enabled,
|
||||
onClickLabel = onClickLabel,
|
||||
onClick = onClick
|
||||
)
|
||||
.scale(scale)
|
||||
}
|
||||
@ -1,9 +1,15 @@
|
||||
package com.bitchat.android.ui
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.Verified
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import androidx.compose.material.icons.outlined.NoEncryption
|
||||
import androidx.compose.material.icons.outlined.Sync
|
||||
import androidx.compose.material.icons.outlined.Warning as OutlinedWarning
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@ -12,13 +18,6 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.Verified
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import androidx.compose.material.icons.outlined.NoEncryption
|
||||
import androidx.compose.material.icons.outlined.Sync
|
||||
import androidx.compose.material.icons.outlined.Warning as OutlinedWarning
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
@ -39,14 +38,15 @@ import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.bitchat.android.ui.theme.BitchatFontFamily
|
||||
import com.bitchat.android.R
|
||||
import com.bitchat.android.core.ui.component.button.CloseButton
|
||||
import com.bitchat.android.core.ui.component.sheet.LocalSheetDismiss
|
||||
import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet
|
||||
|
||||
private data class SecurityStatusInfo(
|
||||
@ -69,9 +69,9 @@ fun SecurityVerificationSheet(
|
||||
val verifiedFingerprints by viewModel.verifiedFingerprints.collectAsStateWithLifecycle()
|
||||
val peerSessionStates by viewModel.peerSessionStates.collectAsStateWithLifecycle()
|
||||
|
||||
val isDark = isSystemInDarkTheme()
|
||||
val accent = if (isDark) Color.Green else Color(0xFF008000)
|
||||
val boxColor = if (isDark) Color.White.copy(alpha = 0.06f) else Color.Black.copy(alpha = 0.06f)
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val accent = colorScheme.primary
|
||||
val boxColor = colorScheme.surfaceVariant
|
||||
val peerHexRegex = remember { Regex("^[0-9a-fA-F]{16}$") }
|
||||
|
||||
BitchatBottomSheet(
|
||||
@ -92,7 +92,7 @@ fun SecurityVerificationSheet(
|
||||
if (peerID == null) {
|
||||
Text(
|
||||
text = stringResource(R.string.fingerprint_no_peer),
|
||||
style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace),
|
||||
style = MaterialTheme.typography.bodyMedium.copy(fontFamily = BitchatFontFamily),
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
|
||||
)
|
||||
} else {
|
||||
@ -155,13 +155,14 @@ private fun SecurityVerificationHeader(
|
||||
Text(
|
||||
text = stringResource(R.string.security_verification_title),
|
||||
style = MaterialTheme.typography.titleSmall.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontWeight = FontWeight.Bold
|
||||
),
|
||||
color = accent
|
||||
)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
CloseButton(onClick = onClose)
|
||||
val dismiss = LocalSheetDismiss.current
|
||||
CloseButton(onClick = { dismiss?.invoke() ?: onClose() })
|
||||
}
|
||||
}
|
||||
|
||||
@ -219,7 +220,7 @@ private fun SecurityStatusCard(
|
||||
Text(
|
||||
text = displayName,
|
||||
style = MaterialTheme.typography.titleMedium.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontWeight = FontWeight.Bold
|
||||
),
|
||||
color = accent
|
||||
@ -227,7 +228,7 @@ private fun SecurityStatusCard(
|
||||
Text(
|
||||
text = statusInfo.text,
|
||||
style = MaterialTheme.typography.bodySmall.copy(
|
||||
fontFamily = FontFamily.Monospace
|
||||
fontFamily = BitchatFontFamily
|
||||
),
|
||||
color = accent.copy(alpha = 0.8f)
|
||||
)
|
||||
@ -257,7 +258,7 @@ private fun SecurityVerificationActions(
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.fingerprint_start_handshake),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 12.sp
|
||||
)
|
||||
}
|
||||
@ -273,7 +274,7 @@ private fun SecurityVerificationActions(
|
||||
Text(
|
||||
text = stringResource(R.string.fingerprint_verified_message),
|
||||
style = MaterialTheme.typography.bodySmall.copy(
|
||||
fontFamily = FontFamily.Monospace
|
||||
fontFamily = BitchatFontFamily
|
||||
),
|
||||
color = accent.copy(alpha = 0.7f),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@ -289,7 +290,7 @@ private fun SecurityVerificationActions(
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.verify_remove),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 12.sp
|
||||
)
|
||||
}
|
||||
@ -303,7 +304,7 @@ private fun SecurityVerificationActions(
|
||||
Text(
|
||||
text = stringResource(R.string.fingerprint_not_verified_message_fmt, displayName),
|
||||
style = MaterialTheme.typography.bodySmall.copy(
|
||||
fontFamily = FontFamily.Monospace
|
||||
fontFamily = BitchatFontFamily
|
||||
),
|
||||
color = accent.copy(alpha = 0.7f),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@ -320,7 +321,7 @@ private fun SecurityVerificationActions(
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.fingerprint_mark_verified),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 12.sp
|
||||
)
|
||||
}
|
||||
@ -349,7 +350,7 @@ private fun VerificationStatusRow(
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontWeight = FontWeight.Bold
|
||||
),
|
||||
color = textTint
|
||||
@ -375,7 +376,7 @@ private fun FingerprintBlock(
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.labelSmall.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontWeight = FontWeight.Bold
|
||||
),
|
||||
color = accent.copy(alpha = 0.8f)
|
||||
@ -385,7 +386,7 @@ private fun FingerprintBlock(
|
||||
Text(
|
||||
text = formatFingerprint(fingerprint),
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 14.sp
|
||||
),
|
||||
color = accent,
|
||||
@ -417,7 +418,7 @@ private fun FingerprintBlock(
|
||||
} else {
|
||||
Text(
|
||||
text = stringResource(R.string.fingerprint_pending),
|
||||
style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace),
|
||||
style = MaterialTheme.typography.bodyMedium.copy(fontFamily = BitchatFontFamily),
|
||||
color = Color(0xFFFF9500),
|
||||
modifier = Modifier.padding(16.dp)
|
||||
)
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
package com.bitchat.android.ui
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.QrCodeScanner
|
||||
import android.graphics.Bitmap
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
@ -17,7 +19,6 @@ import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@ -31,8 +32,6 @@ import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.QrCodeScanner
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
@ -60,7 +59,6 @@ import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
@ -70,8 +68,10 @@ import androidx.core.graphics.createBitmap
|
||||
import androidx.core.graphics.set
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.bitchat.android.ui.theme.BitchatFontFamily
|
||||
import com.bitchat.android.R
|
||||
import com.bitchat.android.core.ui.component.button.CloseButton
|
||||
import com.bitchat.android.core.ui.component.sheet.LocalSheetDismiss
|
||||
import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet
|
||||
import com.bitchat.android.services.VerificationService
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
@ -98,8 +98,7 @@ fun VerificationSheet(
|
||||
) {
|
||||
if (!isPresented) return
|
||||
|
||||
val isDark = isSystemInDarkTheme()
|
||||
val accent = if (isDark) Color.Green else Color(0xFF008000)
|
||||
val accent = MaterialTheme.colorScheme.primary
|
||||
|
||||
var selectedTab by remember { mutableStateOf(0) } // 0 = My Code, 1 = Scan
|
||||
val nickname by viewModel.nickname.collectAsStateWithLifecycle()
|
||||
@ -144,7 +143,7 @@ fun VerificationSheet(
|
||||
text = {
|
||||
Text(
|
||||
text = "My QR",
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 14.sp
|
||||
)
|
||||
}
|
||||
@ -155,7 +154,7 @@ fun VerificationSheet(
|
||||
text = {
|
||||
Text(
|
||||
text = "Scan",
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 14.sp
|
||||
)
|
||||
}
|
||||
@ -208,7 +207,7 @@ fun VerificationSheet(
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.verify_remove),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 12.sp
|
||||
)
|
||||
}
|
||||
@ -232,10 +231,11 @@ private fun VerificationHeader(
|
||||
Text(
|
||||
text = stringResource(R.string.verify_title).uppercase(),
|
||||
fontSize = 14.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
color = accent
|
||||
)
|
||||
CloseButton(onClick = onClose)
|
||||
val dismiss = LocalSheetDismiss.current
|
||||
CloseButton(onClick = { dismiss?.invoke() ?: onClose() })
|
||||
}
|
||||
}
|
||||
|
||||
@ -258,7 +258,7 @@ private fun MyQrTabContent(
|
||||
Text(
|
||||
text = stringResource(R.string.verify_my_qr_title),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
color = accent
|
||||
)
|
||||
|
||||
@ -283,7 +283,7 @@ private fun MyQrTabContent(
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.verify_qr_unavailable),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 12.sp,
|
||||
color = Color.Black.copy(alpha = 0.6f)
|
||||
)
|
||||
@ -296,7 +296,7 @@ private fun MyQrTabContent(
|
||||
Text(
|
||||
text = nickname,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
@ -307,7 +307,7 @@ private fun MyQrTabContent(
|
||||
Text(
|
||||
text = stringResource(R.string.app_name).lowercase(),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
@ -355,7 +355,7 @@ private fun ScanTabContent(
|
||||
Text(
|
||||
text = stringResource(R.string.verify_scan_prompt_friend),
|
||||
color = Color.White,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 12.sp,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
@ -386,7 +386,7 @@ private fun ScanTabContent(
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.verify_camera_permission),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
@ -397,7 +397,7 @@ private fun ScanTabContent(
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.verify_request_camera),
|
||||
fontFamily = FontFamily.Monospace
|
||||
fontFamily = BitchatFontFamily
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,24 +1,18 @@
|
||||
package com.bitchat.android.ui
|
||||
|
||||
import android.Manifest
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Mic
|
||||
import android.Manifest
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.bitchat.android.features.voice.VoiceRecorder
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import com.google.accompanist.permissions.PermissionStatus
|
||||
@ -27,21 +21,59 @@ import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
|
||||
/**
|
||||
* How long the button must be held before a recording starts.
|
||||
*
|
||||
* Push-to-talk should require intent. Firing on raw pointer-down meant a stray touch started a
|
||||
* recording, and it also made the control impossible to defend against pointer events it should
|
||||
* never have seen (see [ArmDelayMs]).
|
||||
*/
|
||||
private const val HoldToRecordMs = 220L
|
||||
|
||||
/**
|
||||
* How long the button ignores presses after entering composition.
|
||||
*
|
||||
* The action cluster swaps send out for camera+microphone the instant the field is cleared, which
|
||||
* puts the microphone exactly where the send button was a frame earlier. Tapping send quickly
|
||||
* could hand the microphone a pointer-down whose matching pointer-up had already been delivered
|
||||
* to the send button that no longer exists — leaving the gesture waiting for a release that will
|
||||
* never come, stuck in "recording" until the composable is disposed. Refusing presses until the
|
||||
* swap animation has settled removes that whole class of failure.
|
||||
*/
|
||||
private const val ArmDelayMs = 350L
|
||||
|
||||
/** Hard cap on a single recording. */
|
||||
private const val MaxRecordingMs = 10_000L
|
||||
|
||||
/** Tail kept after release so the last syllable is not clipped. */
|
||||
private const val ReleaseTailMs = 500L
|
||||
|
||||
@OptIn(ExperimentalPermissionsApi::class)
|
||||
@Composable
|
||||
fun VoiceRecordButton(
|
||||
modifier: Modifier = Modifier,
|
||||
backgroundColor: Color,
|
||||
/**
|
||||
* Recording state as the composer sees it. Drives the active tint so the button and the
|
||||
* pill's border change together instead of one lagging the other.
|
||||
*/
|
||||
isRecording: Boolean = false,
|
||||
onStart: () -> Unit,
|
||||
onAmplitude: (amplitude: Int, elapsedMs: Long) -> Unit,
|
||||
onFinish: (filePath: String) -> Unit
|
||||
onFinish: (filePath: String) -> Unit,
|
||||
/**
|
||||
* Invoked whenever a recording ends without producing a file — permission denied, recorder
|
||||
* failure, or the button being torn down mid-capture. The caller needs this to clear its own
|
||||
* recording state; without it a failed capture left the composer stuck in recording mode.
|
||||
*/
|
||||
onCancel: () -> Unit = {}
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val haptic = LocalHapticFeedback.current
|
||||
val micPermission = rememberPermissionState(Manifest.permission.RECORD_AUDIO)
|
||||
|
||||
var isRecording by remember { mutableStateOf(false) }
|
||||
var isCapturing by remember { mutableStateOf(false) }
|
||||
var recorder by remember { mutableStateOf<VoiceRecorder?>(null) }
|
||||
var recordedFilePath by remember { mutableStateOf<String?>(null) }
|
||||
var recordingStart by remember { mutableStateOf(0L) }
|
||||
@ -53,71 +85,127 @@ fun VoiceRecordButton(
|
||||
val latestOnStart = rememberUpdatedState(onStart)
|
||||
val latestOnAmplitude = rememberUpdatedState(onAmplitude)
|
||||
val latestOnFinish = rememberUpdatedState(onFinish)
|
||||
val latestOnCancel = rememberUpdatedState(onCancel)
|
||||
|
||||
Box(
|
||||
// Set when this instance was composed, so presses inherited from whatever occupied this spot
|
||||
// beforehand can be rejected.
|
||||
val composedAt = remember { System.currentTimeMillis() }
|
||||
|
||||
fun buzz() {
|
||||
try {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
|
||||
// Last line of defence: if the button is removed while capturing — the cluster swapping to
|
||||
// send, the sheet closing, the screen going away — release the recorder and tell the caller,
|
||||
// so nothing is left holding the microphone or showing a recording UI.
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
ampJob?.cancel()
|
||||
ampJob = null
|
||||
if (isCapturing) {
|
||||
isCapturing = false
|
||||
runCatching { recorder?.stop() }
|
||||
recorder = null
|
||||
recordedFilePath = null
|
||||
latestOnCancel.value()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Same disc, same sizing and the same press feedback as the camera and send buttons.
|
||||
ComposerActionSurface(
|
||||
isActive = isRecording || isCapturing,
|
||||
isPressed = isCapturing,
|
||||
modifier = modifier
|
||||
.size(32.dp)
|
||||
.background(backgroundColor, CircleShape)
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures(
|
||||
onPress = {
|
||||
if (!isRecording) {
|
||||
if (micPermission.status !is PermissionStatus.Granted) {
|
||||
micPermission.launchPermissionRequest()
|
||||
return@detectTapGestures
|
||||
}
|
||||
val rec = VoiceRecorder(context)
|
||||
val f = rec.start()
|
||||
recorder = rec
|
||||
isRecording = f != null
|
||||
recordedFilePath = f?.absolutePath
|
||||
recordingStart = System.currentTimeMillis()
|
||||
if (isRecording) {
|
||||
latestOnStart.value()
|
||||
// Haptic "knock" when recording starts
|
||||
try { haptic.performHapticFeedback(HapticFeedbackType.LongPress) } catch (_: Exception) {}
|
||||
// Start amplitude polling loop
|
||||
ampJob?.cancel()
|
||||
ampJob = scope.launch {
|
||||
while (isActive && isRecording) {
|
||||
val amp = recorder?.pollAmplitude() ?: 0
|
||||
val elapsedMs = (System.currentTimeMillis() - recordingStart).coerceAtLeast(0L)
|
||||
latestOnAmplitude.value(amp, elapsedMs)
|
||||
// Auto-stop after 10 seconds
|
||||
if (elapsedMs >= 10_000 && isRecording) {
|
||||
val file = recorder?.stop()
|
||||
isRecording = false
|
||||
recorder = null
|
||||
val path = file?.absolutePath
|
||||
if (!path.isNullOrBlank()) {
|
||||
// Haptic "knock" on auto stop
|
||||
try { haptic.performHapticFeedback(HapticFeedbackType.LongPress) } catch (_: Exception) {}
|
||||
latestOnFinish.value(path)
|
||||
}
|
||||
break
|
||||
}
|
||||
delay(80)
|
||||
// Guard 1: ignore anything arriving before the swap animation settled.
|
||||
if (System.currentTimeMillis() - composedAt < ArmDelayMs) {
|
||||
return@detectTapGestures
|
||||
}
|
||||
// Guard 2: never start a second capture on top of a live one.
|
||||
if (isCapturing) return@detectTapGestures
|
||||
|
||||
if (micPermission.status !is PermissionStatus.Granted) {
|
||||
micPermission.launchPermissionRequest()
|
||||
return@detectTapGestures
|
||||
}
|
||||
|
||||
// Guard 3: require a deliberate hold. `tryAwaitRelease` returns true on
|
||||
// release and false on cancellation; either way the press was not a hold,
|
||||
// so nothing should happen. Only a timeout means the finger is still down.
|
||||
val stillHeld = withTimeoutOrNull(HoldToRecordMs) {
|
||||
tryAwaitRelease()
|
||||
} == null
|
||||
if (!stillHeld) return@detectTapGestures
|
||||
|
||||
val rec = VoiceRecorder(context)
|
||||
val startedFile = rec.start()
|
||||
if (startedFile == null) {
|
||||
// Recorder refused to start; make sure the caller does not sit in a
|
||||
// recording state that never began.
|
||||
runCatching { rec.stop() }
|
||||
latestOnCancel.value()
|
||||
return@detectTapGestures
|
||||
}
|
||||
|
||||
recorder = rec
|
||||
recordedFilePath = startedFile.absolutePath
|
||||
recordingStart = System.currentTimeMillis()
|
||||
isCapturing = true
|
||||
latestOnStart.value()
|
||||
buzz()
|
||||
|
||||
ampJob?.cancel()
|
||||
ampJob = scope.launch {
|
||||
while (isActive && isCapturing) {
|
||||
val amp = recorder?.pollAmplitude() ?: 0
|
||||
val elapsed =
|
||||
(System.currentTimeMillis() - recordingStart).coerceAtLeast(0L)
|
||||
latestOnAmplitude.value(amp, elapsed)
|
||||
|
||||
if (elapsed >= MaxRecordingMs && isCapturing) {
|
||||
val file = recorder?.stop()
|
||||
isCapturing = false
|
||||
recorder = null
|
||||
val path = file?.absolutePath ?: recordedFilePath
|
||||
recordedFilePath = null
|
||||
buzz()
|
||||
// Always report the outcome, even when the file is unusable,
|
||||
// or the caller stays stuck showing the waveform.
|
||||
if (!path.isNullOrBlank()) {
|
||||
latestOnFinish.value(path)
|
||||
} else {
|
||||
latestOnCancel.value()
|
||||
}
|
||||
break
|
||||
}
|
||||
delay(80)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
awaitRelease()
|
||||
tryAwaitRelease()
|
||||
} finally {
|
||||
if (isRecording) {
|
||||
// Extend recording for 500ms after release to avoid clipping
|
||||
delay(500)
|
||||
if (isCapturing) {
|
||||
// Keep going briefly past the release so the tail is not clipped.
|
||||
delay(ReleaseTailMs)
|
||||
}
|
||||
if (isRecording) {
|
||||
if (isCapturing) {
|
||||
val file = recorder?.stop()
|
||||
isRecording = false
|
||||
isCapturing = false
|
||||
recorder = null
|
||||
val path = (file?.absolutePath ?: recordedFilePath)
|
||||
val path = file?.absolutePath ?: recordedFilePath
|
||||
recordedFilePath = null
|
||||
buzz()
|
||||
if (!path.isNullOrBlank()) {
|
||||
// Haptic "knock" when recording stops
|
||||
try { haptic.performHapticFeedback(HapticFeedbackType.LongPress) } catch (_: Exception) {}
|
||||
latestOnFinish.value(path)
|
||||
} else {
|
||||
latestOnCancel.value()
|
||||
}
|
||||
}
|
||||
ampJob?.cancel()
|
||||
@ -125,14 +213,13 @@ fun VoiceRecordButton(
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
}
|
||||
) { tint ->
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Mic,
|
||||
contentDescription = stringResource(com.bitchat.android.R.string.cd_record_voice),
|
||||
tint = Color.Black,
|
||||
modifier = Modifier.size(20.dp)
|
||||
tint = tint,
|
||||
modifier = Modifier.size(ComposerIconSize)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,6 +3,14 @@ package com.bitchat.android.ui.debug
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.widget.Toast
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Bluetooth
|
||||
import androidx.compose.material.icons.filled.Wifi
|
||||
import androidx.compose.material.icons.filled.WifiTethering
|
||||
import androidx.compose.material.icons.filled.BugReport
|
||||
import androidx.compose.material.icons.filled.Devices
|
||||
import androidx.compose.material.icons.filled.PowerSettingsNew
|
||||
import androidx.compose.material.icons.filled.SettingsEthernet
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
@ -12,25 +20,17 @@ import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Bluetooth
|
||||
import androidx.compose.material.icons.filled.Wifi
|
||||
import androidx.compose.material.icons.filled.WifiTethering
|
||||
import androidx.compose.material.icons.filled.BugReport
|
||||
import androidx.compose.material.icons.filled.Devices
|
||||
import androidx.compose.material.icons.filled.PowerSettingsNew
|
||||
import androidx.compose.material.icons.filled.SettingsEthernet
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.draw.rotate
|
||||
import com.bitchat.android.ui.theme.BitchatFontFamily
|
||||
import com.bitchat.android.mesh.BluetoothMeshService
|
||||
import com.bitchat.android.services.meshgraph.MeshGraphService
|
||||
import kotlinx.coroutines.launch
|
||||
@ -67,13 +67,13 @@ fun MeshTopologySection(
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Icon(Icons.Filled.SettingsEthernet, contentDescription = null, tint = Color(0xFF8E8E93))
|
||||
Text("mesh topology", fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium)
|
||||
Text("Mesh topology", fontFamily = BitchatFontFamily, fontSize = 14.sp, fontWeight = FontWeight.Medium)
|
||||
}
|
||||
val nodes = snapshot.nodes
|
||||
val edges = snapshot.edges
|
||||
val empty = nodes.isEmpty()
|
||||
if (empty) {
|
||||
Text("no gossip yet", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f))
|
||||
Text("No gossip yet", fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f))
|
||||
} else {
|
||||
ForceDirectedMeshGraph(
|
||||
nodes = nodes,
|
||||
@ -94,10 +94,10 @@ fun MeshTopologySection(
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
nodes.forEach { node ->
|
||||
val label = "${node.peerID.take(8)} • ${node.nickname ?: "unknown"}"
|
||||
val label = "${node.peerID.take(8)} • ${node.nickname ?: "Unknown"}"
|
||||
Text(
|
||||
text = label,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 11.sp,
|
||||
color = colorScheme.onSurface.copy(alpha = 0.85f)
|
||||
)
|
||||
@ -128,7 +128,7 @@ private fun DistributionInfoSection(info: DistributionInfoProvider.DistributionI
|
||||
Icon(Icons.Filled.Devices, contentDescription = null, tint = Color(0xFF5856D6))
|
||||
Text(
|
||||
"Distribution info",
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
@ -137,7 +137,7 @@ private fun DistributionInfoSection(info: DistributionInfoProvider.DistributionI
|
||||
if (info == null) {
|
||||
Text(
|
||||
"Inspecting installed package…",
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 11.sp,
|
||||
color = colorScheme.onSurface.copy(alpha = 0.6f)
|
||||
)
|
||||
@ -170,7 +170,7 @@ private fun DistributionInfoSection(info: DistributionInfoProvider.DistributionI
|
||||
},
|
||||
contentPadding = PaddingValues(horizontal = 0.dp)
|
||||
) {
|
||||
Text("Copy certificate fingerprint", fontFamily = FontFamily.Monospace)
|
||||
Text("Copy certificate fingerprint", fontFamily = BitchatFontFamily)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -184,13 +184,13 @@ private fun DistributionInfoRow(label: String, value: String) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(
|
||||
label,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 10.sp,
|
||||
color = colorScheme.onSurface.copy(alpha = 0.55f)
|
||||
)
|
||||
Text(
|
||||
value,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 11.sp,
|
||||
color = colorScheme.onSurface.copy(alpha = 0.9f)
|
||||
)
|
||||
@ -347,7 +347,7 @@ fun DebugSettingsSheet(
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(R.string.debug_tools_desc),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 12.sp,
|
||||
color = colorScheme.onSurface.copy(alpha = 0.7f)
|
||||
)
|
||||
@ -361,13 +361,13 @@ fun DebugSettingsSheet(
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Icon(Icons.Filled.SettingsEthernet, contentDescription = null, tint = Color(0xFF00C851))
|
||||
Text(stringResource(R.string.debug_verbose_logging), fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium)
|
||||
Text(stringResource(R.string.debug_verbose_logging), fontFamily = BitchatFontFamily, fontSize = 14.sp, fontWeight = FontWeight.Medium)
|
||||
Spacer(Modifier.weight(1f))
|
||||
Switch(checked = verboseLogging, onCheckedChange = { manager.setVerboseLoggingEnabled(it) })
|
||||
}
|
||||
Text(
|
||||
stringResource(R.string.debug_verbose_hint),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 11.sp,
|
||||
color = colorScheme.onSurface.copy(alpha = 0.7f)
|
||||
)
|
||||
@ -392,10 +392,10 @@ fun DebugSettingsSheet(
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Icon(Icons.Filled.Bluetooth, contentDescription = null, tint = Color(0xFF007AFF))
|
||||
Text(stringResource(R.string.debug_bluetooth_roles), fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium)
|
||||
Text(stringResource(R.string.debug_bluetooth_roles), fontFamily = BitchatFontFamily, fontSize = 14.sp, fontWeight = FontWeight.Medium)
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(stringResource(R.string.debug_gatt_server), fontFamily = FontFamily.Monospace, modifier = Modifier.weight(1f))
|
||||
Text(stringResource(R.string.debug_gatt_server), fontFamily = BitchatFontFamily, modifier = Modifier.weight(1f))
|
||||
Switch(checked = gattServerEnabled, onCheckedChange = {
|
||||
manager.setGattServerEnabled(it)
|
||||
scope.launch {
|
||||
@ -404,9 +404,9 @@ fun DebugSettingsSheet(
|
||||
})
|
||||
}
|
||||
val serverCount = connectedDevices.count { it.connectionType == ConnectionType.GATT_SERVER }
|
||||
Text(stringResource(R.string.debug_connections_fmt, serverCount, maxServer), fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
|
||||
Text(stringResource(R.string.debug_connections_fmt, serverCount, maxServer), fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(stringResource(R.string.debug_max_server), fontFamily = FontFamily.Monospace, modifier = Modifier.width(90.dp))
|
||||
Text(stringResource(R.string.debug_max_server), fontFamily = BitchatFontFamily, modifier = Modifier.width(90.dp))
|
||||
Slider(
|
||||
value = maxServer.toFloat(),
|
||||
onValueChange = { manager.setMaxServerConnections(it.toInt().coerceAtLeast(1)) },
|
||||
@ -415,7 +415,7 @@ fun DebugSettingsSheet(
|
||||
)
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(stringResource(R.string.debug_gatt_client), fontFamily = FontFamily.Monospace, modifier = Modifier.weight(1f))
|
||||
Text(stringResource(R.string.debug_gatt_client), fontFamily = BitchatFontFamily, modifier = Modifier.weight(1f))
|
||||
Switch(checked = gattClientEnabled, onCheckedChange = {
|
||||
manager.setGattClientEnabled(it)
|
||||
scope.launch {
|
||||
@ -424,9 +424,9 @@ fun DebugSettingsSheet(
|
||||
})
|
||||
}
|
||||
val clientCount = connectedDevices.count { it.connectionType == ConnectionType.GATT_CLIENT }
|
||||
Text(stringResource(R.string.debug_connections_fmt, clientCount, maxClient), fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
|
||||
Text(stringResource(R.string.debug_connections_fmt, clientCount, maxClient), fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(stringResource(R.string.debug_max_client), fontFamily = FontFamily.Monospace, modifier = Modifier.width(90.dp))
|
||||
Text(stringResource(R.string.debug_max_client), fontFamily = BitchatFontFamily, modifier = Modifier.width(90.dp))
|
||||
Slider(
|
||||
value = maxClient.toFloat(),
|
||||
onValueChange = { manager.setMaxClientConnections(it.toInt().coerceAtLeast(1)) },
|
||||
@ -435,9 +435,9 @@ fun DebugSettingsSheet(
|
||||
)
|
||||
}
|
||||
val overallCount = connectedDevices.size
|
||||
Text(stringResource(R.string.debug_overall_connections_fmt, overallCount, maxOverall), fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
|
||||
Text(stringResource(R.string.debug_overall_connections_fmt, overallCount, maxOverall), fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(stringResource(R.string.debug_max_overall), fontFamily = FontFamily.Monospace, modifier = Modifier.width(90.dp))
|
||||
Text(stringResource(R.string.debug_max_overall), fontFamily = BitchatFontFamily, modifier = Modifier.width(90.dp))
|
||||
Slider(
|
||||
value = maxOverall.toFloat(),
|
||||
onValueChange = { manager.setMaxConnectionsOverall(it.toInt().coerceAtLeast(1)) },
|
||||
@ -447,7 +447,7 @@ fun DebugSettingsSheet(
|
||||
}
|
||||
Text(
|
||||
stringResource(R.string.debug_roles_hint),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 11.sp,
|
||||
color = colorScheme.onSurface.copy(alpha = 0.7f)
|
||||
)
|
||||
@ -461,12 +461,12 @@ fun DebugSettingsSheet(
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Icon(Icons.Filled.Devices, contentDescription = null, tint = Color(0xFF4CAF50))
|
||||
Text("Transports", fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium)
|
||||
Text("Transports", fontFamily = BitchatFontFamily, fontSize = 14.sp, fontWeight = FontWeight.Medium)
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(Icons.Filled.Bluetooth, contentDescription = null, tint = Color(0xFF007AFF))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("BLE", fontFamily = FontFamily.Monospace, modifier = Modifier.weight(1f))
|
||||
Text("BLE", fontFamily = BitchatFontFamily, modifier = Modifier.weight(1f))
|
||||
Switch(checked = bleEnabled, onCheckedChange = {
|
||||
manager.setBleEnabled(it)
|
||||
})
|
||||
@ -474,7 +474,7 @@ fun DebugSettingsSheet(
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(Icons.Filled.Wifi, contentDescription = null, tint = Color(0xFF9C27B0))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Wi‑Fi Aware", fontFamily = FontFamily.Monospace, modifier = Modifier.weight(1f))
|
||||
Text("Wi‑Fi Aware", fontFamily = BitchatFontFamily, modifier = Modifier.weight(1f))
|
||||
val wifiSwitchEnabled = wifiAwareSupported
|
||||
Text(
|
||||
when {
|
||||
@ -482,7 +482,7 @@ fun DebugSettingsSheet(
|
||||
wifiAwareAvailable -> "available"
|
||||
else -> "unavailable"
|
||||
},
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 11.sp,
|
||||
color = colorScheme.onSurface.copy(alpha = 0.6f)
|
||||
)
|
||||
@ -497,7 +497,7 @@ fun DebugSettingsSheet(
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Spacer(Modifier.width(24.dp))
|
||||
Text("Wi‑Fi Aware verbose", fontFamily = FontFamily.Monospace, modifier = Modifier.weight(1f))
|
||||
Text("Wi‑Fi Aware verbose logging", fontFamily = BitchatFontFamily, modifier = Modifier.weight(1f))
|
||||
Switch(checked = wifiAwareVerbose, onCheckedChange = { manager.setWifiAwareVerbose(it) })
|
||||
}
|
||||
}
|
||||
@ -512,7 +512,7 @@ fun DebugSettingsSheet(
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Icon(Icons.Filled.PowerSettingsNew, contentDescription = null, tint = Color(0xFFFF9500))
|
||||
Text(stringResource(R.string.debug_packet_relay), fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium)
|
||||
Text(stringResource(R.string.debug_packet_relay), fontFamily = BitchatFontFamily, fontSize = 14.sp, fontWeight = FontWeight.Medium)
|
||||
Spacer(Modifier.weight(1f))
|
||||
Switch(checked = packetRelayed, onCheckedChange = { manager.setPacketRelayEnabled(it) })
|
||||
}
|
||||
@ -648,10 +648,10 @@ fun DebugSettingsSheet(
|
||||
// Helper functions moved to top-level composable below to avoid scope issues
|
||||
|
||||
// Render two blocks: Incoming and Outgoing
|
||||
Text("Incoming", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
|
||||
Text("Incoming", fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
|
||||
Text(
|
||||
"${relayStats.lastSecondIncoming}/s • ${relayStats.lastMinuteIncoming}/m • ${relayStats.last15MinuteIncoming}/15m • total ${relayStats.totalIncomingCount}",
|
||||
fontFamily = FontFamily.Monospace, fontSize = 10.sp, color = colorScheme.onSurface.copy(alpha = 0.6f)
|
||||
fontFamily = BitchatFontFamily, fontSize = 10.sp, color = colorScheme.onSurface.copy(alpha = 0.6f)
|
||||
)
|
||||
DrawGraphBlock(
|
||||
title = "Incoming",
|
||||
@ -704,10 +704,10 @@ fun DebugSettingsSheet(
|
||||
if (graphMode != GraphMode.OVERALL && stackedKeysIncoming.isNotEmpty()) { /* legend printed inside DrawGraphBlock */ }
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text("Outgoing", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
|
||||
Text("Outgoing", fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
|
||||
Text(
|
||||
"${relayStats.lastSecondOutgoing}/s • ${relayStats.lastMinuteOutgoing}/m • ${relayStats.last15MinuteOutgoing}/15m • total ${relayStats.totalOutgoingCount}",
|
||||
fontFamily = FontFamily.Monospace, fontSize = 10.sp, color = colorScheme.onSurface.copy(alpha = 0.6f)
|
||||
fontFamily = BitchatFontFamily, fontSize = 10.sp, color = colorScheme.onSurface.copy(alpha = 0.6f)
|
||||
)
|
||||
DrawGraphBlock(
|
||||
title = "Outgoing",
|
||||
@ -770,7 +770,7 @@ fun DebugSettingsSheet(
|
||||
val running by com.bitchat.android.wifiaware.WifiAwareController.running.collectAsState()
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Icon(Icons.Filled.WifiTethering, contentDescription = null, tint = Color(0xFF9C27B0))
|
||||
Text("Wi‑Fi Aware", fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium)
|
||||
Text("Wi‑Fi Aware", fontFamily = BitchatFontFamily, fontSize = 14.sp, fontWeight = FontWeight.Medium)
|
||||
Spacer(Modifier.weight(1f))
|
||||
val wifiStatusText = when {
|
||||
!wifiAwareSupported -> "unsupported"
|
||||
@ -778,12 +778,12 @@ fun DebugSettingsSheet(
|
||||
!wifiAwareAvailable -> "unavailable"
|
||||
else -> "stopped"
|
||||
}
|
||||
Text(wifiStatusText, fontFamily = FontFamily.Monospace, fontSize = 12.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
|
||||
Text(wifiStatusText, fontFamily = BitchatFontFamily, fontSize = 12.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
|
||||
}
|
||||
if (!wifiAwareSupported) {
|
||||
Text(
|
||||
wifiAwareSupportStatus?.reason ?: "Wi-Fi Aware is not supported on this device",
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 11.sp,
|
||||
color = colorScheme.onSurface.copy(alpha = 0.6f)
|
||||
)
|
||||
@ -801,21 +801,21 @@ fun DebugSettingsSheet(
|
||||
label = { Text("Announce") }
|
||||
)
|
||||
}
|
||||
Text("Discovered: ${wifiAwareDiscovered.size}", fontFamily = FontFamily.Monospace, fontSize = 12.sp)
|
||||
Text("Discovered: ${wifiAwareDiscovered.size}", fontFamily = BitchatFontFamily, fontSize = 12.sp)
|
||||
if (wifiAwareDiscovered.isEmpty()) {
|
||||
Text("No discoveries yet", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f))
|
||||
Text("No discoveries yet", fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f))
|
||||
} else {
|
||||
wifiAwareDiscovered.entries.take(50).forEach { (peer, nick) ->
|
||||
Text("• ${if (nick.isBlank()) peer.take(8) + "…" else nick} (${peer.take(8)}…) ", fontFamily = FontFamily.Monospace, fontSize = 12.sp)
|
||||
Text("• ${if (nick.isBlank()) peer.take(8) + "…" else nick} (${peer.take(8)}…) ", fontFamily = BitchatFontFamily, fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
Divider()
|
||||
Text("Connected: ${wifiAwareConnected.size}", fontFamily = FontFamily.Monospace, fontSize = 12.sp)
|
||||
Text("Connected: ${wifiAwareConnected.size}", fontFamily = BitchatFontFamily, fontSize = 12.sp)
|
||||
if (wifiAwareConnected.isEmpty()) {
|
||||
Text("No active sockets", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f))
|
||||
Text("No active sockets", fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f))
|
||||
} else {
|
||||
wifiAwareConnected.entries.take(50).forEach { (peer, ip) ->
|
||||
Text("• ${peer.take(8)}… @ $ip", fontFamily = FontFamily.Monospace, fontSize = 12.sp)
|
||||
Text("• ${peer.take(8)}… @ $ip", fontFamily = BitchatFontFamily, fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -828,17 +828,17 @@ fun DebugSettingsSheet(
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Icon(Icons.Filled.SettingsEthernet, contentDescription = null, tint = Color(0xFF9C27B0))
|
||||
Text("sync settings", fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium)
|
||||
Text("Sync settings", fontFamily = BitchatFontFamily, fontSize = 14.sp, fontWeight = FontWeight.Medium)
|
||||
}
|
||||
Text(stringResource(R.string.debug_max_packets_per_sync_fmt, seenCapacity), fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
|
||||
Text(stringResource(R.string.debug_max_packets_per_sync_fmt, seenCapacity), fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
|
||||
Slider(value = seenCapacity.toFloat(), onValueChange = { manager.setSeenPacketCapacity(it.toInt()) }, valueRange = 10f..1000f, steps = 99)
|
||||
Text(stringResource(R.string.debug_max_gcs_filter_size_fmt, gcsMaxBytes), fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
|
||||
Text(stringResource(R.string.debug_max_gcs_filter_size_fmt, gcsMaxBytes), fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
|
||||
Slider(value = gcsMaxBytes.toFloat(), onValueChange = { manager.setGcsMaxBytes(it.toInt()) }, valueRange = 128f..1024f, steps = 0)
|
||||
Text(stringResource(R.string.debug_target_fpr_fmt, gcsFpr), fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
|
||||
Text(stringResource(R.string.debug_target_fpr_fmt, gcsFpr), fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
|
||||
Slider(value = gcsFpr.toFloat(), onValueChange = { manager.setGcsFprPercent(it.toDouble()) }, valueRange = 0.1f..5.0f, steps = 49)
|
||||
val p = remember(gcsFpr) { com.bitchat.android.sync.GCSFilter.deriveP(gcsFpr / 100.0) }
|
||||
val nmax = remember(gcsFpr, gcsMaxBytes) { com.bitchat.android.sync.GCSFilter.estimateMaxElementsForSize(gcsMaxBytes, p) }
|
||||
Text(stringResource(R.string.debug_derived_p_fmt, p.toString(), nmax.toString()), fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
|
||||
Text(stringResource(R.string.debug_derived_p_fmt, p.toString(), nmax.toString()), fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -849,22 +849,22 @@ fun DebugSettingsSheet(
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Icon(Icons.Filled.Devices, contentDescription = null, tint = Color(0xFF4CAF50))
|
||||
Text(stringResource(R.string.debug_connected_devices), fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium)
|
||||
Text(stringResource(R.string.debug_connected_devices), fontFamily = BitchatFontFamily, fontSize = 14.sp, fontWeight = FontWeight.Medium)
|
||||
}
|
||||
val localAddr = remember { meshService.connectionManager.getLocalAdapterAddress() }
|
||||
Text(stringResource(R.string.debug_our_device_id_fmt, localAddr ?: stringResource(R.string.unknown)), fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
|
||||
Text(stringResource(R.string.debug_our_device_id_fmt, localAddr ?: stringResource(R.string.unknown)), fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
|
||||
if (connectedDevices.isEmpty()) {
|
||||
Text("none", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f))
|
||||
Text("None", fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f))
|
||||
} else {
|
||||
connectedDevices.forEach { dev ->
|
||||
Surface(shape = RoundedCornerShape(8.dp), color = colorScheme.surface.copy(alpha = 0.6f)) {
|
||||
Row(Modifier.fillMaxWidth().padding(10.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text((dev.peerID ?: stringResource(R.string.unknown)) + " • ${dev.deviceAddress}", fontFamily = FontFamily.Monospace, fontSize = 12.sp)
|
||||
Text((dev.peerID ?: stringResource(R.string.unknown)) + " • ${dev.deviceAddress}", fontFamily = BitchatFontFamily, fontSize = 12.sp)
|
||||
val roleLabel = if (dev.connectionType == ConnectionType.GATT_SERVER) stringResource(R.string.debug_role_server) else stringResource(R.string.debug_role_client)
|
||||
Text("${dev.nickname ?: ""} • " + stringResource(R.string.debug_rssi_fmt, dev.rssi ?: stringResource(R.string.debug_question_mark)) + " • $roleLabel" + (if (dev.isDirectConnection) stringResource(R.string.debug_direct_suffix) else ""), fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
|
||||
Text("${dev.nickname ?: ""} • " + stringResource(R.string.debug_rssi_fmt, dev.rssi ?: stringResource(R.string.debug_question_mark)) + " • $roleLabel" + (if (dev.isDirectConnection) stringResource(R.string.debug_direct_suffix) else ""), fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
|
||||
}
|
||||
Text(stringResource(R.string.debug_disconnect), color = Color(0xFFBF1A1A), fontFamily = FontFamily.Monospace, modifier = Modifier.clickable {
|
||||
Text(stringResource(R.string.debug_disconnect), color = Color(0xFFBF1A1A), fontFamily = BitchatFontFamily, modifier = Modifier.clickable {
|
||||
meshService.connectionManager.disconnectAddress(dev.deviceAddress)
|
||||
})
|
||||
}
|
||||
@ -881,19 +881,19 @@ fun DebugSettingsSheet(
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Icon(Icons.Filled.Bluetooth, contentDescription = null, tint = Color(0xFF007AFF))
|
||||
Text(stringResource(R.string.debug_recent_scan_results), fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium)
|
||||
Text(stringResource(R.string.debug_recent_scan_results), fontFamily = BitchatFontFamily, fontSize = 14.sp, fontWeight = FontWeight.Medium)
|
||||
}
|
||||
if (scanResults.isEmpty()) {
|
||||
Text(stringResource(R.string.debug_none), fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f))
|
||||
Text(stringResource(R.string.debug_none), fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f))
|
||||
} else {
|
||||
scanResults.forEach { res ->
|
||||
Surface(shape = RoundedCornerShape(8.dp), color = colorScheme.surface.copy(alpha = 0.6f)) {
|
||||
Row(Modifier.fillMaxWidth().padding(10.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text((res.peerID ?: stringResource(R.string.unknown)) + " • ${res.deviceAddress}", fontFamily = FontFamily.Monospace, fontSize = 12.sp)
|
||||
Text(stringResource(R.string.debug_rssi_fmt, res.rssi.toString()), fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
|
||||
Text((res.peerID ?: stringResource(R.string.unknown)) + " • ${res.deviceAddress}", fontFamily = BitchatFontFamily, fontSize = 12.sp)
|
||||
Text(stringResource(R.string.debug_rssi_fmt, res.rssi.toString()), fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
|
||||
}
|
||||
Text(stringResource(R.string.debug_connect), color = Color(0xFF00C851), fontFamily = FontFamily.Monospace, modifier = Modifier.clickable {
|
||||
Text(stringResource(R.string.debug_connect), color = Color(0xFF00C851), fontFamily = BitchatFontFamily, modifier = Modifier.clickable {
|
||||
meshService.connectionManager.connectToAddress(res.deviceAddress)
|
||||
})
|
||||
}
|
||||
@ -910,15 +910,15 @@ fun DebugSettingsSheet(
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Icon(Icons.Filled.BugReport, contentDescription = null, tint = Color(0xFFFF9500))
|
||||
Text(stringResource(R.string.debug_debug_console), fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium)
|
||||
Text(stringResource(R.string.debug_debug_console), fontFamily = BitchatFontFamily, fontSize = 14.sp, fontWeight = FontWeight.Medium)
|
||||
Spacer(Modifier.weight(1f))
|
||||
Text(stringResource(R.string.debug_clear), color = Color(0xFFBF1A1A), fontFamily = FontFamily.Monospace, modifier = Modifier.clickable {
|
||||
Text(stringResource(R.string.debug_clear), color = Color(0xFFBF1A1A), fontFamily = BitchatFontFamily, modifier = Modifier.clickable {
|
||||
manager.clearDebugMessages()
|
||||
})
|
||||
}
|
||||
Column(Modifier.heightIn(max = 260.dp).background(colorScheme.surface.copy(alpha = 0.5f)).padding(8.dp)) {
|
||||
debugMessages.takeLast(100).reversed().forEach { msg ->
|
||||
Text("${msg.content}", fontFamily = FontFamily.Monospace, fontSize = 11.sp)
|
||||
Text("${msg.content}", fontFamily = BitchatFontFamily, fontSize = 11.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1039,7 +1039,7 @@ private fun DrawGraphBlock(
|
||||
Box(Modifier.width(leftGutter).fillMaxHeight()) {
|
||||
Text(
|
||||
"p/s",
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 10.sp,
|
||||
color = colorScheme.onSurface.copy(alpha = 0.7f),
|
||||
modifier = Modifier.align(Alignment.CenterStart).padding(start = 2.dp).rotate(-90f)
|
||||
@ -1053,14 +1053,14 @@ private fun DrawGraphBlock(
|
||||
}
|
||||
Text(
|
||||
topLabel,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 10.sp,
|
||||
color = colorScheme.onSurface.copy(alpha = 0.7f),
|
||||
modifier = Modifier.align(Alignment.TopEnd).padding(end = 4.dp)
|
||||
)
|
||||
Text(
|
||||
"0",
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 10.sp,
|
||||
color = colorScheme.onSurface.copy(alpha = 0.7f),
|
||||
modifier = Modifier.align(Alignment.BottomEnd).padding(end = 4.dp)
|
||||
@ -1085,8 +1085,8 @@ private fun DrawGraphBlock(
|
||||
) {
|
||||
Box(Modifier.size(10.dp).background(swatchColor, RoundedCornerShape(2.dp)))
|
||||
Column {
|
||||
Text(legendTitleFor(key), fontFamily = FontFamily.Monospace, fontSize = 10.sp, color = MaterialTheme.colorScheme.onSurface.copy(alpha = if (dimmed) 0.6f else 0.95f))
|
||||
Text(legendMetricsFor(key), fontFamily = FontFamily.Monospace, fontSize = 9.sp, color = MaterialTheme.colorScheme.onSurface.copy(alpha = if (dimmed) 0.45f else 0.75f))
|
||||
Text(legendTitleFor(key), fontFamily = BitchatFontFamily, fontSize = 10.sp, color = MaterialTheme.colorScheme.onSurface.copy(alpha = if (dimmed) 0.6f else 0.95f))
|
||||
Text(legendMetricsFor(key), fontFamily = BitchatFontFamily, fontSize = 9.sp, color = MaterialTheme.colorScheme.onSurface.copy(alpha = if (dimmed) 0.45f else 0.75f))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,14 +1,14 @@
|
||||
package com.bitchat.android.ui.debug
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Wifi
|
||||
import androidx.compose.material.icons.outlined.Bluetooth
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.gestures.detectDragGestures
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Wifi
|
||||
import androidx.compose.material.icons.outlined.Bluetooth
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
package com.bitchat.android.ui.media
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
@ -13,14 +13,15 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.bitchat.android.ui.theme.BitchatFontFamily
|
||||
import com.bitchat.android.R
|
||||
import com.bitchat.android.core.ui.component.text.AnnotatedClickableText
|
||||
import com.bitchat.android.mesh.MeshService
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import androidx.compose.material3.ColorScheme
|
||||
import com.bitchat.android.ui.theme.LocalBitchatPalette
|
||||
import java.text.SimpleDateFormat
|
||||
|
||||
@Composable
|
||||
@ -33,8 +34,10 @@ fun AudioMessageItem(
|
||||
onNicknameClick: ((String) -> Unit)?,
|
||||
onMessageLongPress: ((BitchatMessage) -> Unit)?,
|
||||
onCancelTransfer: ((BitchatMessage) -> Unit)?,
|
||||
modifier: Modifier = Modifier
|
||||
modifier: Modifier = Modifier,
|
||||
showSender: Boolean = true
|
||||
) {
|
||||
val palette = LocalBitchatPalette.current
|
||||
val path = message.content.trim()
|
||||
// Derive sending progress if applicable
|
||||
val (overrideProgress, overrideColor) = when (val st = message.deliveryStatus) {
|
||||
@ -50,9 +53,11 @@ fun AudioMessageItem(
|
||||
val headerText = com.bitchat.android.ui.formatMessageHeaderAnnotatedString(
|
||||
message = message,
|
||||
currentUserNickname = currentUserNickname,
|
||||
meshService = meshService,
|
||||
colorScheme = colorScheme,
|
||||
timeFormatter = timeFormatter
|
||||
myPeerID = meshService.myPeerID,
|
||||
palette = palette,
|
||||
contentColor = colorScheme.onSurface,
|
||||
timeFormatter = timeFormatter,
|
||||
includeSender = showSender
|
||||
)
|
||||
val haptic = LocalHapticFeedback.current
|
||||
AnnotatedClickableText(
|
||||
@ -68,7 +73,7 @@ fun AudioMessageItem(
|
||||
}
|
||||
},
|
||||
onLongPress = { onMessageLongPress?.invoke(message) },
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
color = colorScheme.onSurface,
|
||||
)
|
||||
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
package com.bitchat.android.ui.media
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Description
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@ -12,8 +14,6 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Description
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
package com.bitchat.android.ui.media
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Attachment
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Attachment
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
package com.bitchat.android.ui.media
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Description
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
@ -11,8 +13,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Description
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
@ -30,6 +30,7 @@ import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.bitchat.android.ui.theme.BitchatFontFamily
|
||||
import com.bitchat.android.R
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
@ -96,7 +97,7 @@ fun FileSendingAnimation(
|
||||
androidx.compose.material3.Text(
|
||||
text = revealedText,
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
color = Color.White
|
||||
),
|
||||
modifier = Modifier.padding(end = 2.dp)
|
||||
@ -107,7 +108,7 @@ fun FileSendingAnimation(
|
||||
androidx.compose.material3.Text(
|
||||
text = stringResource(R.string.underscore),
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
color = Color.White
|
||||
)
|
||||
)
|
||||
@ -149,7 +150,7 @@ private fun FileProgressBars(
|
||||
androidx.compose.material3.Text(
|
||||
text = progressString,
|
||||
style = MaterialTheme.typography.bodySmall.copy(
|
||||
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
color = Color(0xFF00FF7F) // Matrix green
|
||||
),
|
||||
modifier = modifier
|
||||
|
||||
@ -1,5 +1,8 @@
|
||||
package com.bitchat.android.ui.media
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Download
|
||||
import android.content.ContentValues
|
||||
import android.os.Build
|
||||
import android.provider.MediaStore
|
||||
@ -10,9 +13,6 @@ import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Download
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
@ -33,6 +33,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.bitchat.android.ui.theme.BitchatFontFamily
|
||||
import com.bitchat.android.R
|
||||
import java.io.File
|
||||
|
||||
@ -101,7 +102,7 @@ fun FullScreenImageViewer(imagePaths: List<String>, initialIndex: Int = 0, onClo
|
||||
text = stringResource(R.string.image_counter, (pagerState.currentPage ?: 0) + 1, imagePaths.size),
|
||||
color = Color.White,
|
||||
fontSize = 14.sp,
|
||||
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace
|
||||
fontFamily = BitchatFontFamily
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
package com.bitchat.android.ui.media
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.*
|
||||
@ -21,12 +21,13 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import com.bitchat.android.ui.theme.BitchatFontFamily
|
||||
import com.bitchat.android.mesh.MeshService
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.model.BitchatMessageType
|
||||
import androidx.compose.material3.ColorScheme
|
||||
import com.bitchat.android.core.ui.component.text.AnnotatedClickableText
|
||||
import com.bitchat.android.ui.theme.LocalBitchatPalette
|
||||
import java.text.SimpleDateFormat
|
||||
|
||||
@Composable
|
||||
@ -41,16 +42,20 @@ fun ImageMessageItem(
|
||||
onMessageLongPress: ((BitchatMessage) -> Unit)?,
|
||||
onCancelTransfer: ((BitchatMessage) -> Unit)?,
|
||||
onImageClick: ((String, List<String>, Int) -> Unit)?,
|
||||
modifier: Modifier = Modifier
|
||||
modifier: Modifier = Modifier,
|
||||
showSender: Boolean = true
|
||||
) {
|
||||
val palette = LocalBitchatPalette.current
|
||||
val path = message.content.trim()
|
||||
Column(modifier = modifier.fillMaxWidth()) {
|
||||
val headerText = com.bitchat.android.ui.formatMessageHeaderAnnotatedString(
|
||||
message = message,
|
||||
currentUserNickname = currentUserNickname,
|
||||
meshService = meshService,
|
||||
colorScheme = colorScheme,
|
||||
timeFormatter = timeFormatter
|
||||
myPeerID = meshService.myPeerID,
|
||||
palette = palette,
|
||||
contentColor = colorScheme.onSurface,
|
||||
timeFormatter = timeFormatter,
|
||||
includeSender = showSender
|
||||
)
|
||||
val haptic = LocalHapticFeedback.current
|
||||
AnnotatedClickableText(
|
||||
@ -66,7 +71,7 @@ fun ImageMessageItem(
|
||||
}
|
||||
},
|
||||
onLongPress = { onMessageLongPress?.invoke(message) },
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
color = colorScheme.onSurface,
|
||||
)
|
||||
|
||||
@ -138,7 +143,7 @@ fun ImageMessageItem(
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Text(text = stringResource(com.bitchat.android.R.string.image_unavailable), fontFamily = FontFamily.Monospace, color = Color.Gray)
|
||||
Text(text = stringResource(com.bitchat.android.R.string.image_unavailable), fontFamily = BitchatFontFamily, color = Color.Gray)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,5 +1,9 @@
|
||||
package com.bitchat.android.ui.media
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Camera
|
||||
import androidx.compose.material.icons.filled.Photo
|
||||
import androidx.compose.material.icons.filled.PhotoCamera
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
@ -7,23 +11,20 @@ import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Camera
|
||||
import androidx.compose.material.icons.filled.Photo
|
||||
import androidx.compose.material.icons.filled.PhotoCamera
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.FileProvider
|
||||
import com.bitchat.android.features.media.ImageUtils
|
||||
import com.bitchat.android.ui.ComposerActionSurface
|
||||
import com.bitchat.android.ui.ComposerIconSize
|
||||
import java.io.File
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@ -86,26 +87,31 @@ fun ImagePickerButton(
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.size(32.dp)
|
||||
.combinedClickable(
|
||||
onClick = { imagePicker.launch("image/*") },
|
||||
onLongClick = {
|
||||
if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) {
|
||||
startCameraCapture()
|
||||
} else {
|
||||
permissionLauncher.launch(Manifest.permission.CAMERA)
|
||||
}
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
val isPressed by interactionSource.collectIsPressedAsState()
|
||||
|
||||
// Shares the composer's button treatment so camera, microphone and send read as one set.
|
||||
ComposerActionSurface(
|
||||
isActive = false,
|
||||
isPressed = isPressed,
|
||||
modifier = modifier.combinedClickable(
|
||||
interactionSource = interactionSource,
|
||||
indication = null,
|
||||
onClick = { imagePicker.launch("image/*") },
|
||||
onLongClick = {
|
||||
if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) {
|
||||
startCameraCapture()
|
||||
} else {
|
||||
permissionLauncher.launch(Manifest.permission.CAMERA)
|
||||
}
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
}
|
||||
)
|
||||
) { tint ->
|
||||
Icon(
|
||||
imageVector = Icons.Filled.PhotoCamera,
|
||||
contentDescription = stringResource(com.bitchat.android.R.string.pick_image),
|
||||
tint = Color.Gray,
|
||||
modifier = Modifier.size(20.dp)
|
||||
tint = tint,
|
||||
modifier = Modifier.size(ComposerIconSize)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -1,5 +1,8 @@
|
||||
package com.bitchat.android.ui.media
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Description
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
@ -9,9 +12,6 @@ import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Description
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
@ -1,13 +1,14 @@
|
||||
package com.bitchat.android.ui.media
|
||||
|
||||
import com.bitchat.android.ui.theme.BitchatFontFamily
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Pause
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import android.media.MediaPlayer
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Pause
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material3.FilledTonalIconButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@ -18,7 +19,6 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
|
||||
@Composable
|
||||
fun VoiceNotePlayer(
|
||||
@ -110,7 +110,7 @@ fun VoiceNotePlayer(
|
||||
onSeek = seekTo
|
||||
)
|
||||
val durText = if (durationMs > 0) String.format("%02d:%02d", (durationMs / 1000) / 60, (durationMs / 1000) % 60) else "--:--"
|
||||
Text(text = durText, fontFamily = FontFamily.Monospace, fontSize = 12.sp)
|
||||
Text(text = durText, fontFamily = BitchatFontFamily, fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,91 @@
|
||||
package com.bitchat.android.ui.theme
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
/**
|
||||
* Bitchat-specific color tokens that do not have a faithful Material 3 semantic role.
|
||||
*
|
||||
* Standard backgrounds, surfaces, text, outlines, primary/secondary accents, and errors belong
|
||||
* to [androidx.compose.material3.MaterialTheme.colorScheme]. Keeping only the extra app semantics
|
||||
* here lets Material components inherit correct defaults without losing Bitchat's identity.
|
||||
*/
|
||||
@Immutable
|
||||
data class BitchatPalette(
|
||||
// MARK: - Form controls
|
||||
/**
|
||||
* Resting border for text inputs. Deliberately a neutral grey rather than the green-tinted
|
||||
* Material outline: the composer is the one surface the user stares at while typing.
|
||||
*/
|
||||
val inputOutline: Color,
|
||||
/** Border for a focused text input. A step brighter, still neutral. */
|
||||
val inputOutlineFocused: Color,
|
||||
/**
|
||||
* Fill for text inputs. Near-black / near-white and completely untinted, for the same reason
|
||||
* as [inputOutline] — and because the composer sits on top of a green-tinted scrim, so any
|
||||
* tint of its own compounds into something muddy.
|
||||
*/
|
||||
val inputSurface: Color,
|
||||
/** Fill for a focused text input. A barely perceptible lift. */
|
||||
val inputSurfaceFocused: Color,
|
||||
/** Resting disc behind the composer's action glyphs. Neutral grey. */
|
||||
val inputButton: Color,
|
||||
|
||||
// MARK: - Extra semantics
|
||||
/** Timestamps, placeholders, section labels, disabled states. */
|
||||
val textTertiary: Color,
|
||||
/** Self, mentions targeting you, unread DMs. */
|
||||
val accentOrange: Color,
|
||||
/** Nostr reachability. */
|
||||
val accentPurple: Color,
|
||||
|
||||
// MARK: - Deterministic peer colors
|
||||
/** Chroma applied after deriving a peer's stable hue. */
|
||||
val peerColorSaturation: Float,
|
||||
/** Brightness applied after deriving a peer's stable hue. */
|
||||
val peerColorValue: Float,
|
||||
)
|
||||
|
||||
val DarkBitchatPalette = BitchatPalette(
|
||||
inputOutline = Color(0xFF333635),
|
||||
inputOutlineFocused = Color(0xFF5A605D),
|
||||
inputSurface = Color(0xFF0B0B0B),
|
||||
inputSurfaceFocused = Color(0xFF151515),
|
||||
inputButton = Color(0xFF1E1E1E),
|
||||
textTertiary = Color(0xFF6B776B),
|
||||
accentOrange = Color(0xFFFF9F0A),
|
||||
accentPurple = Color(0xFFBF5AF2),
|
||||
peerColorSaturation = 1f,
|
||||
peerColorValue = 1f,
|
||||
)
|
||||
|
||||
val LightBitchatPalette = BitchatPalette(
|
||||
inputOutline = Color(0xFFCFD3D1),
|
||||
inputOutlineFocused = Color(0xFF8E9490),
|
||||
inputSurface = Color(0xFFFAFAFA),
|
||||
inputSurfaceFocused = Color(0xFFF2F2F2),
|
||||
inputButton = Color(0xFFE8E8E8),
|
||||
textTertiary = Color(0xFF757F75),
|
||||
accentOrange = Color(0xFFFF9500),
|
||||
accentPurple = Color(0xFFAF52DE),
|
||||
peerColorSaturation = 0.85f,
|
||||
peerColorValue = 0.45f,
|
||||
)
|
||||
|
||||
val LocalBitchatPalette = staticCompositionLocalOf { DarkBitchatPalette }
|
||||
|
||||
/**
|
||||
* Motion tokens. The redesign leans on short, snappy transitions: long durations read as
|
||||
* sluggish on a chat surface where the user is scanning quickly.
|
||||
*/
|
||||
object BitchatMotion {
|
||||
/** Icon tints, text colors, small fills. */
|
||||
const val QUICK_MS = 120
|
||||
|
||||
/** Tab indicators, pill growth, chip reveals. */
|
||||
const val STANDARD_MS = 180
|
||||
|
||||
/** Sheet-level fades and scroll-driven top bars. */
|
||||
const val EMPHASIZED_MS = 240
|
||||
}
|
||||
@ -0,0 +1,64 @@
|
||||
package com.bitchat.android.ui.theme
|
||||
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.bitchat.android.R
|
||||
|
||||
/**
|
||||
* The bundled Geist Mono family used throughout the app.
|
||||
*
|
||||
* Keeping the fonts in the APK preserves offline behavior and guarantees that the design-spec
|
||||
* metrics do not depend on which monospace family a device happens to provide.
|
||||
*/
|
||||
internal val BitchatFontFamily = FontFamily(
|
||||
Font(R.font.geist_mono_regular, FontWeight.Normal),
|
||||
Font(R.font.geist_mono_medium, FontWeight.Medium),
|
||||
Font(R.font.geist_mono_semibold, FontWeight.SemiBold),
|
||||
Font(R.font.geist_mono_bold, FontWeight.Bold),
|
||||
)
|
||||
|
||||
/** Exact typography, spacing, and opacity values exported for the chat transcript. */
|
||||
internal object ChatVisualTokens {
|
||||
val MessageBodyFontSize: TextUnit = 14.sp
|
||||
val MessageBodyLineHeight: TextUnit = 20.sp
|
||||
val SenderFontSize: TextUnit = 14.sp
|
||||
val SenderLineHeight: TextUnit = 16.sp
|
||||
val SystemActionFontSize: TextUnit = 12.sp
|
||||
val SystemActionLineHeight: TextUnit = 16.sp
|
||||
val SystemTimeFontSize: TextUnit = 10.sp
|
||||
|
||||
val MessageItemSpacing: Dp = 8.dp
|
||||
val SenderTopPadding: Dp = 8.dp
|
||||
val SenderToBodySpacing: Dp = 4.dp
|
||||
|
||||
const val SenderSuffixAlpha: Float = 0.60f
|
||||
const val HighlightAlpha: Float = 0.20f
|
||||
const val MutedTextAlpha: Float = 0.50f
|
||||
|
||||
val MessageBodyStyle = TextStyle(
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = MessageBodyFontSize,
|
||||
lineHeight = MessageBodyLineHeight,
|
||||
)
|
||||
|
||||
val SenderStyle = TextStyle(
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = SenderFontSize,
|
||||
lineHeight = SenderLineHeight,
|
||||
)
|
||||
|
||||
val SystemActionStyle = TextStyle(
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = SystemActionFontSize,
|
||||
lineHeight = SystemActionLineHeight,
|
||||
)
|
||||
}
|
||||
30
app/src/main/java/com/bitchat/android/ui/theme/PeerColors.kt
Normal file
@ -0,0 +1,30 @@
|
||||
package com.bitchat.android.ui.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import com.bitchat.android.ui.PeerIdentity
|
||||
import kotlin.math.abs
|
||||
|
||||
/**
|
||||
* The single identity-to-color boundary used by chat, people sheets, and mentions.
|
||||
*
|
||||
* The djb2 hash and hue adjustment are byte-identical to the iOS implementation. Orange is
|
||||
* avoided because it is reserved for the current user.
|
||||
*/
|
||||
fun colorForPeer(identity: PeerIdentity, palette: BitchatPalette): Color {
|
||||
var hash = 5381UL
|
||||
for (byte in identity.stableKey.toByteArray()) {
|
||||
hash = ((hash shl 5) + hash) + byte.toUByte().toULong()
|
||||
}
|
||||
|
||||
var hue = (hash % 360UL).toDouble() / 360.0
|
||||
val orange = 30.0 / 360.0
|
||||
if (abs(hue - orange) < 0.05) {
|
||||
hue = (hue + 0.12) % 1.0
|
||||
}
|
||||
|
||||
return Color.hsv(
|
||||
hue = (hue * 360).toFloat(),
|
||||
saturation = palette.peerColorSaturation,
|
||||
value = palette.peerColorValue
|
||||
)
|
||||
}
|
||||