- Adds an Around ME feed

- Refactors location to operate as a flow
- Refactors FeedStructures to prepare for custom feeds
- Moves Account to operate feeds with location
This commit is contained in:
Vitor Pamplona
2024-10-30 16:01:39 -04:00
parent fb6137f99a
commit a5c4a53afe
23 changed files with 473 additions and 292 deletions
@@ -32,6 +32,7 @@ import androidx.security.crypto.EncryptedSharedPreferences
import coil.ImageLoader import coil.ImageLoader
import coil.disk.DiskCache import coil.disk.DiskCache
import coil.memory.MemoryCache import coil.memory.MemoryCache
import com.vitorpamplona.amethyst.service.LocationState
import com.vitorpamplona.amethyst.service.playback.VideoCache import com.vitorpamplona.amethyst.service.playback.VideoCache
import com.vitorpamplona.ammolite.service.HttpClientManager import com.vitorpamplona.ammolite.service.HttpClientManager
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -50,6 +51,7 @@ class Amethyst : Application() {
// Service Manager is only active when the activity is active. // Service Manager is only active when the activity is active.
val serviceManager = ServiceManager(applicationIOScope) val serviceManager = ServiceManager(applicationIOScope)
val locationManager = LocationState(this, applicationIOScope)
override fun onTerminate() { override fun onTerminate() {
super.onTerminate() super.onTerminate()
@@ -20,6 +20,7 @@
*/ */
package com.vitorpamplona.amethyst.model package com.vitorpamplona.amethyst.model
import android.location.Location
import android.util.Log import android.util.Log
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
@@ -28,6 +29,8 @@ import androidx.lifecycle.asLiveData
import androidx.lifecycle.liveData import androidx.lifecycle.liveData
import androidx.lifecycle.switchMap import androidx.lifecycle.switchMap
import com.fasterxml.jackson.module.kotlin.readValue import com.fasterxml.jackson.module.kotlin.readValue
import com.fonfon.kgeohash.toGeoHash
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.service.FileHeader import com.vitorpamplona.amethyst.service.FileHeader
import com.vitorpamplona.amethyst.service.NostrLnZapPaymentResponseDataSource import com.vitorpamplona.amethyst.service.NostrLnZapPaymentResponseDataSource
@@ -119,10 +122,8 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.combineTransform import kotlinx.coroutines.flow.combineTransform
import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.transformLatest import kotlinx.coroutines.flow.transformLatest
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
@@ -158,17 +159,19 @@ class Account(
val transientPaymentRequests: MutableStateFlow<Set<PaymentRequest>> = MutableStateFlow(emptySet()) val transientPaymentRequests: MutableStateFlow<Set<PaymentRequest>> = MutableStateFlow(emptySet())
@Immutable @Immutable
class LiveFollowLists( class LiveFollowList(
val users: Set<String> = emptySet(), val authors: Set<String> = emptySet(),
val usersPlusMe: Set<String>, val authorsPlusMe: Set<String>,
val hashtags: Set<String> = emptySet(), val hashtags: Set<String> = emptySet(),
val geotags: Set<String> = emptySet(), val geotags: Set<String> = emptySet(),
val communities: Set<String> = emptySet(), val addresses: Set<String> = emptySet(),
) )
class ListNameNotePair( class FeedsBaseFlows(
val listName: String, val listName: String,
val event: GeneralListEvent?, val peopleList: StateFlow<NoteState> = MutableStateFlow(NoteState(Note(" "))),
val kind3: StateFlow<Account.LiveFollowList?> = MutableStateFlow(null),
val location: StateFlow<Location?> = MutableStateFlow(null),
) )
val connectToRelaysFlow = val connectToRelaysFlow =
@@ -216,7 +219,7 @@ class Account(
localRelayList: Set<String>, localRelayList: Set<String>,
): List<RelaySetupInfo> { ): List<RelaySetupInfo> {
val newDMRelaySet = newDMRelayEvent?.relays()?.map { RelayUrlFormatter.normalize(it) }?.toSet() ?: emptySet() val newDMRelaySet = newDMRelayEvent?.relays()?.map { RelayUrlFormatter.normalize(it) }?.toSet() ?: emptySet()
val searchRelaySet = (searchRelayEvent?.relays() ?: Constants.defaultSearchRelaySet).map { RelayUrlFormatter.normalize(it) }.toSet() val searchRelaySet = (searchRelayEvent?.relays() ?: DefaultSearchRelayList).map { RelayUrlFormatter.normalize(it) }.toSet()
val nip65RelaySet = val nip65RelaySet =
nip65RelayEvent?.relays()?.map { nip65RelayEvent?.relays()?.map {
AdvertisedRelayListEvent.AdvertisedRelayInfo( AdvertisedRelayListEvent.AdvertisedRelayInfo(
@@ -463,20 +466,23 @@ class Account(
}.toTypedArray(), }.toTypedArray(),
) )
fun buildFollowLists(latestContactList: ContactListEvent?): LiveFollowLists { fun buildFollowLists(latestContactList: ContactListEvent?): LiveFollowList {
// makes sure the output include only valid p tags // makes sure the output include only valid p tags
val verifiedFollowingUsers = latestContactList?.verifiedFollowKeySet() ?: emptySet() val verifiedFollowingUsers = latestContactList?.verifiedFollowKeySet() ?: emptySet()
return LiveFollowLists( return LiveFollowList(
verifiedFollowingUsers, authors = verifiedFollowingUsers,
verifiedFollowingUsers + signer.pubKey, authorsPlusMe = verifiedFollowingUsers + signer.pubKey,
hashtags =
latestContactList latestContactList
?.unverifiedFollowTagSet() ?.unverifiedFollowTagSet()
?.map { it.lowercase() } ?.map { it.lowercase() }
?.toSet() ?: emptySet(), ?.toSet() ?: emptySet(),
geotags =
latestContactList latestContactList
?.unverifiedFollowGeohashSet() ?.unverifiedFollowGeohashSet()
?.toSet() ?: emptySet(), ?.toSet() ?: emptySet(),
addresses =
latestContactList latestContactList
?.verifiedFollowAddressSet() ?.verifiedFollowAddressSet()
?.toSet() ?: emptySet(), ?.toSet() ?: emptySet(),
@@ -514,7 +520,7 @@ class Account(
) )
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
val liveKind3FollowsFlow: Flow<LiveFollowLists> = val liveKind3FollowsFlow: Flow<LiveFollowList> =
userProfile().flow().follows.stateFlow.transformLatest { userProfile().flow().follows.stateFlow.transformLatest {
checkNotInMainThread() checkNotInMainThread()
emit(buildFollowLists(it.user.latestContactList)) emit(buildFollowLists(it.user.latestContactList))
@@ -529,80 +535,108 @@ class Account(
buildFollowLists(userProfile().latestContactList ?: settings.backupContactList), buildFollowLists(userProfile().latestContactList ?: settings.backupContactList),
) )
@OptIn(ExperimentalCoroutinesApi::class) fun loadFlowsFor(listName: String): FeedsBaseFlows =
private val liveHomeList: Flow<ListNameNotePair> = when (listName) {
settings.defaultHomeFollowList.flatMapLatest { listName -> GLOBAL_FOLLOWS -> FeedsBaseFlows(listName)
loadPeopleListFlowFromListName(listName) KIND3_FOLLOWS -> FeedsBaseFlows(listName, kind3 = liveKind3Follows)
} AROUND_ME ->
FeedsBaseFlows(
fun peopleListFromListNameStarter(listName: String): ListNameNotePair = listName,
if (listName != GLOBAL_FOLLOWS && listName != KIND3_FOLLOWS) { location = Amethyst.instance.locationManager.locationStateFlow,
)
else -> {
val note = LocalCache.checkGetOrCreateAddressableNote(listName) val note = LocalCache.checkGetOrCreateAddressableNote(listName)
val noteEvent = note?.event as? GeneralListEvent if (note != null) {
ListNameNotePair(listName, noteEvent) FeedsBaseFlows(
listName,
peopleList =
note
.flow()
.metadata.stateFlow,
)
} else { } else {
ListNameNotePair(listName, null) FeedsBaseFlows(listName)
}
}
} }
@OptIn(ExperimentalCoroutinesApi::class) suspend fun mapIntoFollowLists(
fun loadPeopleListFlowFromListName(listName: String): Flow<ListNameNotePair> = listName: String,
if (listName != GLOBAL_FOLLOWS && listName != KIND3_FOLLOWS) { kind3: LiveFollowList?,
val note = LocalCache.checkGetOrCreateAddressableNote(listName) noteState: NoteState,
note?.flow()?.metadata?.stateFlow?.mapLatest { location: Location?,
val noteEvent = it.note.event as? GeneralListEvent ): LiveFollowList? =
ListNameNotePair(listName, noteEvent) if (listName == GLOBAL_FOLLOWS) {
} ?: MutableStateFlow(ListNameNotePair(listName, null)) println("AABBCC combinePeopleList $listName Global $listName")
} else {
MutableStateFlow(ListNameNotePair(listName, null))
}
suspend fun combinePeopleList(
kind3Follows: LiveFollowLists,
peopleListFollows: ListNameNotePair,
): LiveFollowLists? =
if (peopleListFollows.listName == GLOBAL_FOLLOWS) {
null null
} else if (peopleListFollows.listName == KIND3_FOLLOWS) { } else if (listName == KIND3_FOLLOWS) {
kind3Follows println("AABBCC combinePeopleList $listName Kind3 $kind3")
} else if (peopleListFollows.event == null) { kind3
LiveFollowLists(usersPlusMe = setOf(signer.pubKey)) } else if (listName == AROUND_ME) {
val hash = location?.toGeoHash(com.vitorpamplona.amethyst.ui.actions.GeohashPrecision.KM_5_X_5.digits)
if (hash != null) {
println("AABBCC combinePeopleList AROUND ME Started $listName Kind3 $kind3")
// 2 neighbors deep = 25x25km
val hashes =
listOf(hash.toString()) +
hash.adjacent
.map { listOf(it.toString()) + it.adjacent.map { it.toString() } }
.flatten()
.distinct()
println("AABBCC combinePeopleList AROUND ME Finished $listName Kind3 $kind3")
LiveFollowList(
authorsPlusMe = setOf(signer.pubKey),
geotags = hashes.toSet(),
)
} else { } else {
val result = waitToDecrypt(peopleListFollows.event) LiveFollowList(authorsPlusMe = setOf(signer.pubKey))
if (result == null) { }
LiveFollowLists(usersPlusMe = setOf(signer.pubKey))
} else { } else {
result val peopleList = noteState.note.event as? GeneralListEvent
println("AABBCC combinePeopleList $listName General List ${noteState.note.idHex}")
if (peopleList != null) {
waitToDecrypt(peopleList) ?: LiveFollowList(authorsPlusMe = setOf(signer.pubKey))
} else {
LiveFollowList(authorsPlusMe = setOf(signer.pubKey))
} }
} }
fun combinePeopleListFlows( @OptIn(ExperimentalCoroutinesApi::class)
kind3FollowsSource: Flow<LiveFollowLists>, fun combinePeopleListFlows(peopleListFollowsSource: Flow<String>): Flow<LiveFollowList?> =
peopleListFollowsSource: Flow<ListNameNotePair>, peopleListFollowsSource
): Flow<LiveFollowLists?> = .transformLatest { listName ->
combineTransform(kind3FollowsSource, peopleListFollowsSource) { kind3Follows, peopleListFollows -> val followList = loadFlowsFor(listName)
checkNotInMainThread() emitAll(
emit(combinePeopleList(kind3Follows, peopleListFollows)) combine(followList.kind3, followList.peopleList, followList.location) { kind3, peopleList, location ->
mapIntoFollowLists(followList.listName, kind3, peopleList, location)
},
)
} }
val liveHomeFollowListFlow: Flow<LiveFollowLists?> by lazy { val liveHomeFollowLists: StateFlow<LiveFollowList?> by lazy {
combinePeopleListFlows(liveKind3Follows, liveHomeList) combinePeopleListFlows(settings.defaultHomeFollowList)
}
val liveHomeFollowLists: StateFlow<LiveFollowLists?> by lazy {
liveHomeFollowListFlow
.flowOn(Dispatchers.Default) .flowOn(Dispatchers.Default)
.stateIn( .stateIn(
scope, scope,
SharingStarted.Eagerly, SharingStarted.Eagerly,
runBlocking { runBlocking {
combinePeopleList( loadAndCombineFlows(settings.defaultHomeFollowList.value)
liveKind3Follows.value,
peopleListFromListNameStarter(settings.defaultHomeFollowList.value),
)
}, },
) )
} }
suspend fun loadAndCombineFlows(listName: String): LiveFollowList? {
val flows = loadFlowsFor(listName)
return mapIntoFollowLists(
flows.listName,
flows.kind3.value,
flows.peopleList.value,
flows.location.value,
)
}
/** /**
* filter onion and local host from write relays * filter onion and local host from write relays
* for each user pubkey, a list of valid relays. * for each user pubkey, a list of valid relays.
@@ -699,7 +733,7 @@ class Account(
liveHomeFollowLists liveHomeFollowLists
.transformLatest { followList -> .transformLatest { followList ->
if (followList != null) { if (followList != null) {
emitAll(combine(followList.usersPlusMe.map { getNIP65RelayListFlow(it) }) { it }) emitAll(combine(followList.authorsPlusMe.map { getNIP65RelayListFlow(it) }) { it })
} else { } else {
emit(null) emit(null)
} }
@@ -726,53 +760,33 @@ class Account(
scope, scope,
SharingStarted.Eagerly, SharingStarted.Eagerly,
authorsPerRelay( authorsPerRelay(
liveHomeFollowLists.value?.usersPlusMe?.map { getNIP65RelayListNote(it) } ?: emptyList(), liveHomeFollowLists.value?.authorsPlusMe?.map { getNIP65RelayListNote(it) } ?: emptyList(),
connectToRelays.value.filter { it.feedTypes.contains(FeedType.FOLLOWS) && it.read }.map { it.url }, connectToRelays.value.filter { it.feedTypes.contains(FeedType.FOLLOWS) && it.read }.map { it.url },
settings.torSettings.torType.value, settings.torSettings.torType.value,
).ifEmpty { null }, ).ifEmpty { null },
) )
} }
@OptIn(ExperimentalCoroutinesApi::class) val liveNotificationFollowLists: StateFlow<LiveFollowList?> by lazy {
private val liveNotificationList: Flow<ListNameNotePair> by lazy { combinePeopleListFlows(settings.defaultNotificationFollowList)
settings.defaultNotificationFollowList.flatMapLatest { listName ->
loadPeopleListFlowFromListName(listName)
}
}
val liveNotificationFollowLists: StateFlow<LiveFollowLists?> by lazy {
combinePeopleListFlows(liveKind3FollowsFlow, liveNotificationList)
.flowOn(Dispatchers.Default) .flowOn(Dispatchers.Default)
.stateIn( .stateIn(
scope, scope,
SharingStarted.Eagerly, SharingStarted.Eagerly,
runBlocking { runBlocking {
combinePeopleList( loadAndCombineFlows(settings.defaultNotificationFollowList.value)
liveKind3Follows.value,
peopleListFromListNameStarter(settings.defaultNotificationFollowList.value),
)
}, },
) )
} }
@OptIn(ExperimentalCoroutinesApi::class) val liveStoriesFollowLists: StateFlow<LiveFollowList?> by lazy {
private val liveStoriesList: Flow<ListNameNotePair> by lazy { combinePeopleListFlows(settings.defaultStoriesFollowList)
settings.defaultStoriesFollowList.flatMapLatest { listName ->
loadPeopleListFlowFromListName(listName)
}
}
val liveStoriesFollowLists: StateFlow<LiveFollowLists?> by lazy {
combinePeopleListFlows(liveKind3FollowsFlow, liveStoriesList)
.flowOn(Dispatchers.Default) .flowOn(Dispatchers.Default)
.stateIn( .stateIn(
scope, scope,
SharingStarted.Eagerly, SharingStarted.Eagerly,
runBlocking { runBlocking {
combinePeopleList( loadAndCombineFlows(settings.defaultStoriesFollowList.value)
liveKind3Follows.value,
peopleListFromListNameStarter(settings.defaultStoriesFollowList.value),
)
}, },
) )
} }
@@ -782,7 +796,7 @@ class Account(
liveStoriesFollowLists liveStoriesFollowLists
.transformLatest { followList -> .transformLatest { followList ->
if (followList != null) { if (followList != null) {
emitAll(combine(followList.usersPlusMe.map { getNIP65RelayListFlow(it) }) { it }) emitAll(combine(followList.authorsPlusMe.map { getNIP65RelayListFlow(it) }) { it })
} else { } else {
emit(null) emit(null)
} }
@@ -809,31 +823,21 @@ class Account(
scope, scope,
SharingStarted.Eagerly, SharingStarted.Eagerly,
authorsPerRelay( authorsPerRelay(
liveStoriesFollowLists.value?.usersPlusMe?.map { getNIP65RelayListNote(it) } ?: emptyList(), liveStoriesFollowLists.value?.authorsPlusMe?.map { getNIP65RelayListNote(it) } ?: emptyList(),
connectToRelays.value.filter { it.feedTypes.contains(FeedType.FOLLOWS) && it.read }.map { it.url }, connectToRelays.value.filter { it.feedTypes.contains(FeedType.FOLLOWS) && it.read }.map { it.url },
settings.torSettings.torType.value, settings.torSettings.torType.value,
).ifEmpty { null }, ).ifEmpty { null },
) )
} }
@OptIn(ExperimentalCoroutinesApi::class) val liveDiscoveryFollowLists: StateFlow<LiveFollowList?> by lazy {
private val liveDiscoveryList: Flow<ListNameNotePair> by lazy { combinePeopleListFlows(settings.defaultDiscoveryFollowList)
settings.defaultDiscoveryFollowList.flatMapLatest { listName ->
loadPeopleListFlowFromListName(listName)
}
}
val liveDiscoveryFollowLists: StateFlow<LiveFollowLists?> by lazy {
combinePeopleListFlows(liveKind3FollowsFlow, liveDiscoveryList)
.flowOn(Dispatchers.Default) .flowOn(Dispatchers.Default)
.stateIn( .stateIn(
scope, scope,
SharingStarted.Eagerly, SharingStarted.Eagerly,
runBlocking { runBlocking {
combinePeopleList( loadAndCombineFlows(settings.defaultDiscoveryFollowList.value)
liveKind3Follows.value,
peopleListFromListNameStarter(settings.defaultDiscoveryFollowList.value),
)
}, },
) )
} }
@@ -843,7 +847,7 @@ class Account(
liveDiscoveryFollowLists liveDiscoveryFollowLists
.transformLatest { followList -> .transformLatest { followList ->
if (followList != null) { if (followList != null) {
emitAll(combine(followList.usersPlusMe.map { getNIP65RelayListFlow(it) }) { it }) emitAll(combine(followList.authorsPlusMe.map { getNIP65RelayListFlow(it) }) { it })
} else { } else {
emit(null) emit(null)
} }
@@ -870,7 +874,7 @@ class Account(
scope, scope,
SharingStarted.Eagerly, SharingStarted.Eagerly,
authorsPerRelay( authorsPerRelay(
liveDiscoveryFollowLists.value?.usersPlusMe?.map { getNIP65RelayListNote(it) } ?: emptyList(), liveDiscoveryFollowLists.value?.authorsPlusMe?.map { getNIP65RelayListNote(it) } ?: emptyList(),
connectToRelays.value.filter { it.read }.map { it.url }, connectToRelays.value.filter { it.read }.map { it.url },
settings.torSettings.torType.value, settings.torSettings.torType.value,
).ifEmpty { null }, ).ifEmpty { null },
@@ -879,19 +883,17 @@ class Account(
private fun decryptLiveFollows( private fun decryptLiveFollows(
listEvent: GeneralListEvent, listEvent: GeneralListEvent,
onReady: (LiveFollowLists) -> Unit, onReady: (LiveFollowList) -> Unit,
) { ) {
listEvent.privateTags(signer) { privateTagList -> listEvent.privateTags(signer) { privateTagList ->
val users = (listEvent.bookmarkedPeople() + listEvent.filterUsers(privateTagList)).toSet() val users = (listEvent.bookmarkedPeople() + listEvent.filterUsers(privateTagList)).toSet()
onReady( onReady(
LiveFollowLists( LiveFollowList(
users = users, authors = users,
usersPlusMe = users + userProfile().pubkeyHex, authorsPlusMe = users + userProfile().pubkeyHex,
hashtags = hashtags = (listEvent.hashtags() + listEvent.filterHashtags(privateTagList)).toSet(),
(listEvent.hashtags() + listEvent.filterHashtags(privateTagList)).toSet(), geotags = (listEvent.geohashes() + listEvent.filterGeohashes(privateTagList)).toSet(),
geotags = addresses =
(listEvent.geohashes() + listEvent.filterGeohashes(privateTagList)).toSet(),
communities =
(listEvent.taggedAddresses() + listEvent.filterAddresses(privateTagList)) (listEvent.taggedAddresses() + listEvent.filterAddresses(privateTagList))
.map { it.toTag() } .map { it.toTag() }
.toSet(), .toSet(),
@@ -900,7 +902,7 @@ class Account(
} }
} }
suspend fun waitToDecrypt(peopleListFollows: GeneralListEvent): LiveFollowLists? = suspend fun waitToDecrypt(peopleListFollows: GeneralListEvent): LiveFollowList? =
withTimeoutOrNull(1000) { withTimeoutOrNull(1000) {
suspendCancellableCoroutine { continuation -> suspendCancellableCoroutine { continuation ->
decryptLiveFollows(peopleListFollows) { decryptLiveFollows(peopleListFollows) {
@@ -3192,7 +3194,7 @@ class Account(
flowHiddenUsers.value.hiddenUsers.contains(userHex) || flowHiddenUsers.value.hiddenUsers.contains(userHex) ||
flowHiddenUsers.value.spammers.contains(userHex) flowHiddenUsers.value.spammers.contains(userHex)
fun followingKeySet(): Set<HexKey> = liveKind3Follows.value.users fun followingKeySet(): Set<HexKey> = liveKind3Follows.value.authors
fun isAcceptable(user: User): Boolean { fun isAcceptable(user: User): Boolean {
if (userProfile().pubkeyHex == user.pubkeyHex) { if (userProfile().pubkeyHex == user.pubkeyHex) {
@@ -3250,8 +3252,8 @@ class Account(
} }
return ( return (
note.reportsBy(liveKind3Follows.value.usersPlusMe) + note.reportsBy(liveKind3Follows.value.authorsPlusMe) +
(note.author?.reportsBy(liveKind3Follows.value.usersPlusMe) ?: emptyList()) + (note.author?.reportsBy(liveKind3Follows.value.authorsPlusMe) ?: emptyList()) +
innerReports innerReports
).toSet() ).toSet()
} }
@@ -86,6 +86,9 @@ val GLOBAL_FOLLOWS = " Global "
// This has spaces to avoid mixing with a potential NIP-51 list with the same name. // This has spaces to avoid mixing with a potential NIP-51 list with the same name.
val KIND3_FOLLOWS = " All Follows " val KIND3_FOLLOWS = " All Follows "
// This has spaces to avoid mixing with a potential NIP-51 list with the same name.
val AROUND_ME = " Around Me "
@Stable @Stable
class AccountSettings( class AccountSettings(
val keyPair: KeyPair, val keyPair: KeyPair,
@@ -26,73 +26,90 @@ import android.location.Geocoder
import android.location.Location import android.location.Location
import android.location.LocationListener import android.location.LocationListener
import android.location.LocationManager import android.location.LocationManager
import android.os.HandlerThread import android.os.Looper
import android.util.LruCache import android.util.LruCache
import androidx.compose.runtime.MutableState import com.fonfon.kgeohash.toGeoHash
import androidx.compose.runtime.mutableStateOf import com.vitorpamplona.amethyst.service.LocationState.Companion.MIN_DISTANCE
import com.vitorpamplona.amethyst.service.LocationState.Companion.MIN_TIME
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
class LocationUtil( class LocationFlow(
context: Context, private val context: Context,
) { ) {
companion object {
const val MIN_TIME: Long = 1000L
const val MIN_DISTANCE: Float = 0.0f
}
private val locationManager =
context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
private var locationListener: LocationListener? = null
val locationStateFlow = MutableStateFlow<Location>(Location(LocationManager.NETWORK_PROVIDER))
val providerState = mutableStateOf(false)
val isStart: MutableState<Boolean> = mutableStateOf(false)
private val locHandlerThread = HandlerThread("LocationUtil Thread")
init {
locHandlerThread.start()
}
@SuppressLint("MissingPermission") @SuppressLint("MissingPermission")
fun start( fun get(
minTimeMs: Long = MIN_TIME, minTimeMs: Long = MIN_TIME,
minDistanceM: Float = MIN_DISTANCE, minDistanceM: Float = MIN_DISTANCE,
) { ): Flow<Location> =
locationListener().let { callbackFlow {
locationListener = it val locationManager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
val locationCallback =
object : LocationListener {
override fun onLocationChanged(location: Location) {
launch { send(location) }
}
override fun onProviderEnabled(provider: String) {}
override fun onProviderDisabled(provider: String) {}
}
println("AABBCC LocationState Start")
locationManager.requestLocationUpdates( locationManager.requestLocationUpdates(
LocationManager.NETWORK_PROVIDER, LocationManager.NETWORK_PROVIDER,
minTimeMs, minTimeMs,
minDistanceM, minDistanceM,
it, locationCallback,
locHandlerThread.looper, Looper.getMainLooper(),
) )
awaitClose {
locationManager.removeUpdates(locationCallback)
println("AABBCC LocationState Stop")
} }
providerState.value = locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER) }
isStart.value = true }
class LocationState(
context: Context,
scope: CoroutineScope,
) {
companion object {
const val MIN_TIME: Long = 10000L
const val MIN_DISTANCE: Float = 100.0f
} }
fun stop() { private var latestLocation: Location = Location(LocationManager.NETWORK_PROVIDER)
locationListener?.let { locationManager.removeUpdates(it) }
isStart.value = false
}
private fun locationListener() = val locationStateFlow =
object : LocationListener { LocationFlow(context)
override fun onLocationChanged(location: Location) { .get(MIN_TIME, MIN_DISTANCE)
locationStateFlow.value = location .onEach {
} latestLocation = it
}.stateIn(
scope,
SharingStarted.WhileSubscribed(5000),
latestLocation,
)
override fun onProviderEnabled(provider: String) { val geohashStateFlow =
providerState.value = true locationStateFlow
} .map { it.toGeoHash(com.vitorpamplona.amethyst.ui.actions.GeohashPrecision.KM_5_X_5.digits).toString() }
.stateIn(
override fun onProviderDisabled(provider: String) { scope,
providerState.value = false SharingStarted.WhileSubscribed(5000),
} "",
} )
} }
object CachedGeoLocations { object CachedGeoLocations {
@@ -162,7 +162,7 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
fun createLiveStreamFilter(): List<TypedFilter> { fun createLiveStreamFilter(): List<TypedFilter> {
val follows = val follows =
account.liveDiscoveryFollowLists.value account.liveDiscoveryFollowLists.value
?.users ?.authors
?.toList() ?.toList()
?.ifEmpty { null } ?.ifEmpty { null }
@@ -211,7 +211,7 @@ object NostrHomeDataSource : AmethystNostrDataSource("HomeFeed") {
mapOf( mapOf(
"g" to "g" to
hashToLoad hashToLoad
.map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) } .map { listOf(it.lowercase()) }
.flatten(), .flatten(),
), ),
limit = 100, limit = 100,
@@ -225,7 +225,7 @@ object NostrHomeDataSource : AmethystNostrDataSource("HomeFeed") {
} }
fun createFollowCommunitiesFilter(): TypedFilter? { fun createFollowCommunitiesFilter(): TypedFilter? {
val communitiesToLoad = account.liveHomeFollowLists.value?.communities ?: return null val communitiesToLoad = account.liveHomeFollowLists.value?.addresses ?: return null
if (communitiesToLoad.isEmpty()) return null if (communitiesToLoad.isEmpty()) return null
@@ -35,6 +35,7 @@ import androidx.compose.ui.text.input.TextFieldValue
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.fonfon.kgeohash.toGeoHash import com.fonfon.kgeohash.toGeoHash
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.compose.insertUrlAtCursor import com.vitorpamplona.amethyst.commons.compose.insertUrlAtCursor
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
@@ -43,7 +44,6 @@ import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.FileHeader import com.vitorpamplona.amethyst.service.FileHeader
import com.vitorpamplona.amethyst.service.LocationUtil
import com.vitorpamplona.amethyst.service.Nip96Uploader import com.vitorpamplona.amethyst.service.Nip96Uploader
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
import com.vitorpamplona.amethyst.ui.components.MediaCompressor import com.vitorpamplona.amethyst.ui.components.MediaCompressor
@@ -78,7 +78,10 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.util.UUID import java.util.UUID
@@ -163,8 +166,7 @@ open class NewPostViewModel : ViewModel() {
// GeoHash // GeoHash
var wantsToAddGeoHash by mutableStateOf(false) var wantsToAddGeoHash by mutableStateOf(false)
var locUtil: LocationUtil? = null var location: StateFlow<String?>? = null
var location: Flow<String>? = null
// ZapRaiser // ZapRaiser
var canAddZapRaiser by mutableStateOf(false) var canAddZapRaiser by mutableStateOf(false)
@@ -530,14 +532,7 @@ open class NewPostViewModel : ViewModel() {
null null
} }
val geoLocation = locUtil?.locationStateFlow?.value val geoHash = location?.value
val geoHash =
if (wantsToAddGeoHash && geoLocation != null) {
geoLocation.toGeoHash(GeohashPrecision.KM_5_X_5.digits).toString()
} else {
null
}
val localZapRaiserAmount = if (wantsZapraiser) zapRaiserAmount else null val localZapRaiserAmount = if (wantsZapraiser) zapRaiserAmount else null
nip95attachments.forEach { nip95attachments.forEach {
@@ -1262,28 +1257,20 @@ open class NewPostViewModel : ViewModel() {
} }
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
fun startLocation(context: Context) { fun locationFlow(): Flow<String?> {
locUtil = LocationUtil(context) if (location == null) {
locUtil?.let {
location = location =
it.locationStateFlow.mapLatest { it.toGeoHash(GeohashPrecision.KM_5_X_5.digits).toString() } Amethyst.instance.locationManager.locationStateFlow
saveDraft() .mapLatest { it.toGeoHash(GeohashPrecision.KM_5_X_5.digits).toString() }
} .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null)
viewModelScope.launch(Dispatchers.IO) { locUtil?.start() }
} }
fun stopLocation() { return location!!
viewModelScope.launch(Dispatchers.IO) { locUtil?.stop() }
location = null
locUtil = null
} }
override fun onCleared() { override fun onCleared() {
super.onCleared() super.onCleared()
Log.d("Init", "OnCleared: ${this.javaClass.simpleName}") Log.d("Init", "OnCleared: ${this.javaClass.simpleName}")
viewModelScope.launch(Dispatchers.IO) { locUtil?.stop() }
location = null
locUtil = null
} }
fun toggleNIP04And24() { fun toggleNIP04And24() {
@@ -1386,7 +1373,7 @@ open class NewPostViewModel : ViewModel() {
elementList[i] = elementList[nextIndex].also { elementList[nextIndex] = "null" } elementList[i] = elementList[nextIndex].also { elementList[nextIndex] = "null" }
} }
} }
elementList.removeLast() elementList.removeAt(elementList.size - 1)
val newEntries = keyList.zip(elementList) { key, content -> Pair(key, content) } val newEntries = keyList.zip(elementList) { key, content -> Pair(key, content) }
this.clear() this.clear()
this.putAll(newEntries) this.putAll(newEntries)
@@ -143,7 +143,7 @@ class Kind3RelayListViewModel : ViewModel() {
val proposed = val proposed =
RelayListRecommendationProcessor RelayListRecommendationProcessor
.reliableRelaySetFor( .reliableRelaySetFor(
account.liveKind3Follows.value.users.mapNotNull { account.liveKind3Follows.value.authors.mapNotNull {
account.getNIP65RelayList(it) account.getNIP65RelayList(it)
}, },
relayUrlsToIgnore = relayUrlsToIgnore =
@@ -67,7 +67,7 @@ open class DiscoverLiveFeedFilter(
override fun sort(collection: Set<Note>): List<Note> { override fun sort(collection: Set<Note>): List<Note> {
val followingKeySet = val followingKeySet =
account.liveDiscoveryFollowLists.value?.users ?: account.liveKind3Follows.value.users account.liveDiscoveryFollowLists.value?.authors ?: account.liveKind3Follows.value.authors
val counter = ParticipantListBuilder() val counter = ParticipantListBuilder()
val participantCounts = val participantCounts =
@@ -33,7 +33,7 @@ import com.vitorpamplona.quartz.utils.TimeUtils
class FilterByListParams( class FilterByListParams(
val isGlobal: Boolean, val isGlobal: Boolean,
val isHiddenList: Boolean, val isHiddenList: Boolean,
val followLists: Account.LiveFollowLists?, val followLists: Account.LiveFollowList?,
val hiddenLists: Account.LiveHiddenUsers, val hiddenLists: Account.LiveHiddenUsers,
val now: Long = TimeUtils.oneMinuteFromNow(), val now: Long = TimeUtils.oneMinuteFromNow(),
) { ) {
@@ -45,22 +45,22 @@ class FilterByListParams(
if (followLists == null) return false if (followLists == null) return false
return if (noteEvent is LiveActivitiesEvent) { return if (noteEvent is LiveActivitiesEvent) {
noteEvent.participantsIntersect(followLists.users) || noteEvent.participantsIntersect(followLists.authors) ||
noteEvent.isTaggedHashes(followLists.hashtags) || noteEvent.isTaggedHashes(followLists.hashtags) ||
noteEvent.isTaggedGeoHashes(followLists.geotags) || noteEvent.isTaggedGeoHashes(followLists.geotags) ||
noteEvent.isTaggedAddressableNotes(followLists.communities) noteEvent.isTaggedAddressableNotes(followLists.addresses)
} else { } else {
noteEvent.pubKey in followLists.users || noteEvent.pubKey in followLists.authors ||
noteEvent.isTaggedHashes(followLists.hashtags) || noteEvent.isTaggedHashes(followLists.hashtags) ||
noteEvent.isTaggedGeoHashes(followLists.geotags) || noteEvent.isTaggedGeoHashes(followLists.geotags) ||
noteEvent.isTaggedAddressableNotes(followLists.communities) noteEvent.isTaggedAddressableNotes(followLists.addresses)
} }
} }
fun isATagInList(aTag: ATag): Boolean { fun isATagInList(aTag: ATag): Boolean {
if (followLists == null) return false if (followLists == null) return false
return aTag.pubKeyHex in followLists.users return aTag.pubKeyHex in followLists.authors
} }
fun match( fun match(
@@ -89,7 +89,7 @@ class FilterByListParams(
fun create( fun create(
userHex: String, userHex: String,
selectedListName: String, selectedListName: String,
followLists: Account.LiveFollowLists?, followLists: Account.LiveFollowList?,
hiddenUsers: Account.LiveHiddenUsers, hiddenUsers: Account.LiveHiddenUsers,
): FilterByListParams = ): FilterByListParams =
FilterByListParams( FilterByListParams(
@@ -115,7 +115,7 @@ class NotificationFeedFilter(
it.event !is NIP90ContentDiscoveryRequestEvent && it.event !is NIP90ContentDiscoveryRequestEvent &&
it.event !is GiftWrapEvent && it.event !is GiftWrapEvent &&
(it.event is LnZapEvent || notifAuthor != loggedInUserHex) && (it.event is LnZapEvent || notifAuthor != loggedInUserHex) &&
(filterParams.isGlobal || filterParams.followLists?.users?.contains(notifAuthor) == true) && (filterParams.isGlobal || filterParams.followLists?.authors?.contains(notifAuthor) == true) &&
it.event?.isTaggedUser(loggedInUserHex) ?: false && it.event?.isTaggedUser(loggedInUserHex) ?: false &&
(filterParams.isHiddenList || notifAuthor == null || !account.isHidden(notifAuthor)) && (filterParams.isHiddenList || notifAuthor == null || !account.isHidden(notifAuthor)) &&
tagsAnEventByUser(it, loggedInUserHex) tagsAnEventByUser(it, loggedInUserHex)
@@ -37,7 +37,7 @@ class ThreadFeedFilter(
override fun feed(): List<Note> { override fun feed(): List<Note> {
val cachedSignatures: MutableMap<Note, LevelSignature> = mutableMapOf() val cachedSignatures: MutableMap<Note, LevelSignature> = mutableMapOf()
val followingKeySet = account.liveKind3Follows.value.users val followingKeySet = account.liveKind3Follows.value.authors
val eventsToWatch = ThreadAssembler().findThreadFor(noteId) val eventsToWatch = ThreadAssembler().findThreadFor(noteId)
val eventsInHex = eventsToWatch.map { it.idHex }.toSet() val eventsInHex = eventsToWatch.map { it.idHex }.toSet()
val now = TimeUtils.now() val now = TimeUtils.now()
@@ -38,7 +38,7 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.FeatureSetType import com.vitorpamplona.amethyst.model.FeatureSetType
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.note.SearchIcon import com.vitorpamplona.amethyst.ui.note.SearchIcon
import com.vitorpamplona.amethyst.ui.screen.CodeName import com.vitorpamplona.amethyst.ui.screen.FeedDefinition
import com.vitorpamplona.amethyst.ui.screen.FollowListState import com.vitorpamplona.amethyst.ui.screen.FollowListState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
@@ -112,7 +112,7 @@ private fun LoggedInUserPictureDrawer(
fun FollowListWithRoutes( fun FollowListWithRoutes(
followListsModel: FollowListState, followListsModel: FollowListState,
listName: String, listName: String,
onChange: (CodeName) -> Unit, onChange: (FeedDefinition) -> Unit,
) { ) {
val allLists by followListsModel.kind3GlobalPeopleRoutes.collectAsStateWithLifecycle() val allLists by followListsModel.kind3GlobalPeopleRoutes.collectAsStateWithLifecycle()
@@ -128,7 +128,7 @@ fun FollowListWithRoutes(
fun FollowListWithoutRoutes( fun FollowListWithoutRoutes(
followListsModel: FollowListState, followListsModel: FollowListState,
listName: String, listName: String,
onChange: (CodeName) -> Unit, onChange: (FeedDefinition) -> Unit,
) { ) {
val allLists by followListsModel.kind3GlobalPeople.collectAsStateWithLifecycle() val allLists by followListsModel.kind3GlobalPeople.collectAsStateWithLifecycle()
@@ -394,7 +394,7 @@ private fun FollowingAndFollowerCounts(
) { ) {
Text( Text(
text = text =
followingCount.value.users.size followingCount.value.authors.size
.toString(), .toString(),
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
) )
@@ -20,10 +20,12 @@
*/ */
package com.vitorpamplona.amethyst.ui.navigation package com.vitorpamplona.amethyst.ui.navigation
import android.Manifest
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
@@ -33,6 +35,8 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -41,12 +45,20 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.map import androidx.lifecycle.map
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.ui.actions.LoadingAnimation
import com.vitorpamplona.amethyst.ui.note.LoadCityName import com.vitorpamplona.amethyst.ui.note.LoadCityName
import com.vitorpamplona.amethyst.ui.screen.CodeName import com.vitorpamplona.amethyst.ui.screen.AroundMeFeedDefinition
import com.vitorpamplona.amethyst.ui.screen.CommunityName import com.vitorpamplona.amethyst.ui.screen.CommunityName
import com.vitorpamplona.amethyst.ui.screen.FeedDefinition
import com.vitorpamplona.amethyst.ui.screen.GeoHashName import com.vitorpamplona.amethyst.ui.screen.GeoHashName
import com.vitorpamplona.amethyst.ui.screen.HashtagName import com.vitorpamplona.amethyst.ui.screen.HashtagName
import com.vitorpamplona.amethyst.ui.screen.Name import com.vitorpamplona.amethyst.ui.screen.Name
@@ -55,15 +67,18 @@ import com.vitorpamplona.amethyst.ui.screen.ResourceName
import com.vitorpamplona.amethyst.ui.screen.loggedIn.SpinnerSelectionDialog import com.vitorpamplona.amethyst.ui.screen.loggedIn.SpinnerSelectionDialog
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.events.PeopleListEvent import com.vitorpamplona.quartz.events.PeopleListEvent
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.flow.map
@OptIn(ExperimentalPermissionsApi::class)
@Composable @Composable
fun FeedFilterSpinner( fun FeedFilterSpinner(
placeholderCode: String, placeholderCode: String,
explainer: String, explainer: String,
options: ImmutableList<CodeName>, options: ImmutableList<FeedDefinition>,
onSelect: (Int) -> Unit, onSelect: (Int) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
@@ -75,20 +90,61 @@ fun FeedFilterSpinner(
id = R.string.select_an_option, id = R.string.select_an_option,
) )
var currentText by var selected by
remember(placeholderCode, options) { remember(placeholderCode, options) {
mutableStateOf( mutableStateOf(
options.firstOrNull { it.code == placeholderCode }?.name?.name(context) ?: selectAnOption, options.firstOrNull { it.code == placeholderCode },
) )
} }
val currentText by
remember(placeholderCode, options) {
derivedStateOf {
selected?.name?.name(context) ?: selectAnOption
}
}
Box( Box(
modifier = modifier, modifier = modifier,
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Spacer(modifier = Size20Modifier) Spacer(modifier = Size20Modifier)
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(currentText) Text(currentText)
if (selected is AroundMeFeedDefinition) {
val locationPermissionState =
rememberPermissionState(
Manifest.permission.ACCESS_COARSE_LOCATION,
)
if (!locationPermissionState.status.isGranted) {
LaunchedEffect(locationPermissionState) { locationPermissionState.launchPermissionRequest() }
} else {
val location by Amethyst.instance.locationManager.geohashStateFlow
.collectAsStateWithLifecycle(null)
location?.let {
LoadCityName(
geohashStr = it,
onLoading = {
Spacer(modifier = StdHorzSpacer)
LoadingAnimation()
},
) { cityName ->
Text(
text = "($cityName)",
fontSize = 12.sp,
lineHeight = 12.sp,
)
}
}
}
}
}
Icon( Icon(
imageVector = Icons.Default.ExpandMore, imageVector = Icons.Default.ExpandMore,
contentDescription = explainer, contentDescription = explainer,
@@ -115,7 +171,7 @@ fun FeedFilterSpinner(
options = options, options = options,
onDismiss = { optionsShowing = false }, onDismiss = { optionsShowing = false },
onSelect = { onSelect = {
currentText = options[it].name.name(context) selected = options[it]
optionsShowing = false optionsShowing = false
onSelect(it) onSelect(it)
}, },
@@ -671,13 +671,13 @@ fun LoadModerators(
val followingKeySet = val followingKeySet =
accountViewModel.account.liveDiscoveryFollowLists.value accountViewModel.account.liveDiscoveryFollowLists.value
?.users ?.authors
val allParticipants = val allParticipants =
ParticipantListBuilder().followsThatParticipateOn(baseNote, followingKeySet).minus(hosts) ParticipantListBuilder().followsThatParticipateOn(baseNote, followingKeySet).minus(hosts)
val newParticipantUsers = val newParticipantUsers =
if (followingKeySet == null) { if (followingKeySet == null) {
val allFollows = accountViewModel.account.liveKind3Follows.value.users val allFollows = accountViewModel.account.liveKind3Follows.value.authors
val followingParticipants = val followingParticipants =
ParticipantListBuilder().followsThatParticipateOn(baseNote, allFollows).minus(hosts) ParticipantListBuilder().followsThatParticipateOn(baseNote, allFollows).minus(hosts)
@@ -724,7 +724,7 @@ private fun LoadParticipants(
val followingKeySet = val followingKeySet =
accountViewModel.account.liveDiscoveryFollowLists.value accountViewModel.account.liveDiscoveryFollowLists.value
?.users ?.authors
val allParticipants = val allParticipants =
ParticipantListBuilder() ParticipantListBuilder()
@@ -733,7 +733,7 @@ private fun LoadParticipants(
val newParticipantUsers = val newParticipantUsers =
if (followingKeySet == null) { if (followingKeySet == null) {
val allFollows = accountViewModel.account.liveKind3Follows.value.users val allFollows = accountViewModel.account.liveKind3Follows.value.authors
val followingParticipants = val followingParticipants =
ParticipantListBuilder() ParticipantListBuilder()
.followsThatParticipateOn(baseNote, allFollows) .followsThatParticipateOn(baseNote, allFollows)
@@ -882,7 +882,7 @@ fun RenderChannelThumb(
launch(Dispatchers.IO) { launch(Dispatchers.IO) {
val followingKeySet = val followingKeySet =
accountViewModel.account.liveDiscoveryFollowLists.value accountViewModel.account.liveDiscoveryFollowLists.value
?.users ?.authors
val allParticipants = val allParticipants =
ParticipantListBuilder() ParticipantListBuilder()
.followsThatParticipateOn(baseNote, followingKeySet) .followsThatParticipateOn(baseNote, followingKeySet)
@@ -890,7 +890,7 @@ fun RenderChannelThumb(
val newParticipantUsers = val newParticipantUsers =
if (followingKeySet == null) { if (followingKeySet == null) {
val allFollows = accountViewModel.account.liveKind3Follows.value.users val allFollows = accountViewModel.account.liveKind3Follows.value.authors
val followingParticipants = val followingParticipants =
ParticipantListBuilder().followsThatParticipateOn(baseNote, allFollows).toList() ParticipantListBuilder().followsThatParticipateOn(baseNote, allFollows).toList()
@@ -373,6 +373,6 @@ fun WatchUserFollows(
} else { } else {
val state by accountViewModel.account.liveKind3Follows.collectAsStateWithLifecycle() val state by accountViewModel.account.liveKind3Follows.collectAsStateWithLifecycle()
onFollowChanges(state.users.contains(userHex)) onFollowChanges(state.authors.contains(userHex))
} }
} }
@@ -355,5 +355,5 @@ fun WatchAddressableNoteFollows(
) { ) {
val state by accountViewModel.account.liveKind3Follows.collectAsStateWithLifecycle() val state by accountViewModel.account.liveKind3Follows.collectAsStateWithLifecycle()
onFollowChanges(state.communities.contains(note.idHex)) onFollowChanges(state.addresses.contains(note.idHex))
} }
@@ -25,6 +25,7 @@ import android.util.Log
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.AROUND_ME
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
@@ -33,10 +34,24 @@ import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.events.AudioHeaderEvent
import com.vitorpamplona.quartz.events.AudioTrackEvent
import com.vitorpamplona.quartz.events.ClassifiedsEvent
import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent
import com.vitorpamplona.quartz.events.ContactListEvent import com.vitorpamplona.quartz.events.ContactListEvent
import com.vitorpamplona.quartz.events.DeletionEvent import com.vitorpamplona.quartz.events.DeletionEvent
import com.vitorpamplona.quartz.events.GenericRepostEvent
import com.vitorpamplona.quartz.events.HighlightEvent
import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.events.LiveActivitiesEvent
import com.vitorpamplona.quartz.events.LongTextNoteEvent
import com.vitorpamplona.quartz.events.MuteListEvent import com.vitorpamplona.quartz.events.MuteListEvent
import com.vitorpamplona.quartz.events.PeopleListEvent import com.vitorpamplona.quartz.events.PeopleListEvent
import com.vitorpamplona.quartz.events.PinListEvent
import com.vitorpamplona.quartz.events.PollNoteEvent
import com.vitorpamplona.quartz.events.RepostEvent
import com.vitorpamplona.quartz.events.TextNoteEvent
import com.vitorpamplona.quartz.events.WikiNoteEvent
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -56,33 +71,56 @@ class FollowListState(
val viewModelScope: CoroutineScope, val viewModelScope: CoroutineScope,
) { ) {
val kind3Follow = val kind3Follow =
CodeName( PeopleListOutBoxFeedDefinition(
KIND3_FOLLOWS, code = KIND3_FOLLOWS,
ResourceName(R.string.follow_list_kind3follows), name = ResourceName(R.string.follow_list_kind3follows),
CodeNameType.HARDCODED, type = CodeNameType.HARDCODED,
kinds = DEFAULT_FEED_KINDS,
unpackList = listOf(ContactListEvent.blockListFor(account.signer.pubKey)),
) )
val globalFollow =
CodeName(GLOBAL_FOLLOWS, ResourceName(R.string.follow_list_global), CodeNameType.HARDCODED)
val muteListFollow =
CodeName(
MuteListEvent.blockListFor(account.userProfile().pubkeyHex),
ResourceName(R.string.follow_list_mute_list),
CodeNameType.HARDCODED,
)
val defaultLists = persistentListOf(kind3Follow, globalFollow, muteListFollow)
fun getPeopleLists(): List<CodeName> = val globalFollow =
GlobalFeedDefinition(
code = GLOBAL_FOLLOWS,
name = ResourceName(R.string.follow_list_global),
type = CodeNameType.HARDCODED,
kinds = DEFAULT_FEED_KINDS,
relays = account.activeGlobalRelays().toList(),
)
val aroundMe =
AroundMeFeedDefinition(
code = AROUND_ME,
name = ResourceName(R.string.follow_list_aroundme),
type = CodeNameType.HARDCODED,
kinds = DEFAULT_FEED_KINDS,
)
val muteListFollow =
PeopleListOutBoxFeedDefinition(
code = MuteListEvent.blockListFor(account.userProfile().pubkeyHex),
name = ResourceName(R.string.follow_list_mute_list),
type = CodeNameType.HARDCODED,
kinds = DEFAULT_FEED_KINDS,
unpackList = listOf(MuteListEvent.blockListFor(account.userProfile().pubkeyHex)),
)
val defaultLists = persistentListOf(kind3Follow, globalFollow, aroundMe, muteListFollow)
fun getPeopleLists(): List<FeedDefinition> =
account account
.getAllPeopleLists() .getAllPeopleLists()
.map { .map {
CodeName( PeopleListOutBoxFeedDefinition(
it.idHex, it.idHex,
PeopleListName(it), PeopleListName(it),
CodeNameType.PEOPLE_LIST, CodeNameType.PEOPLE_LIST,
kinds = DEFAULT_FEED_KINDS,
listOf(it.idHex),
) )
}.sortedBy { it.name.name() } }.sortedBy { it.name.name() }
val livePeopleListsFlow = MutableStateFlow(emptyList<CodeName>()) val livePeopleListsFlow = MutableStateFlow(emptyList<FeedDefinition>())
fun updateFeedWith(newNotes: Set<Note>) { fun updateFeedWith(newNotes: Set<Note>) {
checkNotInMainThread() checkNotInMainThread()
@@ -118,29 +156,46 @@ class FollowListState(
} }
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
val liveKind3FollowsFlow: Flow<List<CodeName>> = val liveKind3FollowsFlow: Flow<List<FeedDefinition>> =
account.liveKind3Follows.transformLatest { account.liveKind3Follows.transformLatest {
checkNotInMainThread() checkNotInMainThread()
val communities = val communities =
it.communities.mapNotNull { it.addresses.mapNotNull {
LocalCache.checkGetOrCreateAddressableNote(it)?.let { communityNote -> LocalCache.checkGetOrCreateAddressableNote(it)?.let { communityNote ->
CodeName( TagFeedDefinition(
"Community/${communityNote.idHex}", "Community/${communityNote.idHex}",
CommunityName(communityNote), CommunityName(communityNote),
CodeNameType.ROUTE, CodeNameType.ROUTE,
kinds = DEFAULT_COMMUNITY_FEEDS,
aTags = listOf(communityNote.idHex),
relays = account.activeGlobalRelays().toList(),
) )
} }
} }
val hashtags = val hashtags =
it.hashtags.map { it.hashtags.map {
CodeName("Hashtag/$it", HashtagName(it), CodeNameType.ROUTE) TagFeedDefinition(
"Hashtag/$it",
HashtagName(it),
CodeNameType.ROUTE,
kinds = DEFAULT_FEED_KINDS,
tTags = listOf(it),
relays = account.activeGlobalRelays().toList(),
)
} }
val geotags = val geotags =
it.geotags.map { it.geotags.map {
CodeName("Geohash/$it", GeoHashName(it), CodeNameType.ROUTE) TagFeedDefinition(
"Geohash/$it",
GeoHashName(it),
CodeNameType.ROUTE,
kinds = DEFAULT_FEED_KINDS,
gTags = listOf(it),
relays = account.activeGlobalRelays().toList(),
)
} }
emit( emit(
@@ -156,7 +211,7 @@ class FollowListState(
checkNotInMainThread() checkNotInMainThread()
emit( emit(
listOf( listOf(
listOf(kind3Follow, globalFollow), listOf(kind3Follow, aroundMe, globalFollow),
myLivePeopleListsFlow, myLivePeopleListsFlow,
myLiveKind3FollowsFlow, myLiveKind3FollowsFlow,
listOf(muteListFollow), listOf(muteListFollow),
@@ -172,7 +227,7 @@ class FollowListState(
checkNotInMainThread() checkNotInMainThread()
emit( emit(
listOf( listOf(
listOf(kind3Follow, globalFollow), listOf(kind3Follow, aroundMe, globalFollow),
myLivePeopleListsFlow, myLivePeopleListsFlow,
listOf(muteListFollow), listOf(muteListFollow),
).flatten().toImmutableList(), ).flatten().toImmutableList(),
@@ -238,8 +293,78 @@ class CommunityName(
} }
@Immutable @Immutable
data class CodeName( abstract class FeedDefinition(
val code: String, val code: String,
val name: Name, val name: Name,
val type: CodeNameType, val type: CodeNameType,
) )
@Immutable
class GlobalFeedDefinition(
code: String,
name: Name,
type: CodeNameType,
val kinds: List<Int>,
val relays: List<String>,
) : FeedDefinition(code, name, type)
@Immutable
class TagFeedDefinition(
code: String,
name: Name,
type: CodeNameType,
val kinds: List<Int>,
val relays: List<String>,
val pTags: List<String>? = null,
val eTags: List<String>? = null,
val aTags: List<String>? = null,
val tTags: List<String>? = null,
val gTags: List<String>? = null,
) : FeedDefinition(code, name, type)
@Immutable
class AroundMeFeedDefinition(
code: String,
name: Name,
type: CodeNameType,
val kinds: List<Int>,
) : FeedDefinition(code, name, type)
@Immutable
class PeopleListOutBoxFeedDefinition(
code: String,
name: Name,
type: CodeNameType,
val kinds: List<Int>,
val unpackList: List<String>,
) : FeedDefinition(code, name, type)
val DEFAULT_FEED_KINDS =
listOf(
TextNoteEvent.KIND,
RepostEvent.KIND,
GenericRepostEvent.KIND,
ClassifiedsEvent.KIND,
LongTextNoteEvent.KIND,
PollNoteEvent.KIND,
HighlightEvent.KIND,
AudioTrackEvent.KIND,
AudioHeaderEvent.KIND,
PinListEvent.KIND,
LiveActivitiesChatMessageEvent.KIND,
LiveActivitiesEvent.KIND,
WikiNoteEvent.KIND,
)
val DEFAULT_COMMUNITY_FEEDS =
listOf(
TextNoteEvent.KIND,
LongTextNoteEvent.KIND,
ClassifiedsEvent.KIND,
HighlightEvent.KIND,
AudioHeaderEvent.KIND,
AudioTrackEvent.KIND,
PinListEvent.KIND,
WikiNoteEvent.KIND,
CommunityPostApprovalEvent.KIND,
)
@@ -120,7 +120,6 @@ import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.joinAll import kotlinx.coroutines.joinAll
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.suspendCancellableCoroutine
@@ -396,7 +395,7 @@ class AccountViewModel(
note.flow().metadata.stateFlow, note.flow().metadata.stateFlow,
note.flow().reports.stateFlow, note.flow().reports.stateFlow,
) { hiddenUsers, followingUsers, autor, metadata, reports -> ) { hiddenUsers, followingUsers, autor, metadata, reports ->
emit(isNoteAcceptable(metadata.note, hiddenUsers, followingUsers.users)) emit(isNoteAcceptable(metadata.note, hiddenUsers, followingUsers.authors))
}.flowOn(Dispatchers.Default) }.flowOn(Dispatchers.Default)
.stateIn( .stateIn(
viewModelScope, viewModelScope,
@@ -188,7 +188,6 @@ import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.receiveAsFlow
@@ -1335,21 +1334,9 @@ fun LocationAsHash(postViewModel: NewPostViewModel) {
@Composable @Composable
fun DisplayLocationObserver(postViewModel: NewPostViewModel) { fun DisplayLocationObserver(postViewModel: NewPostViewModel) {
val context = LocalContext.current val location by postViewModel.locationFlow().collectAsStateWithLifecycle(null)
var locationDescriptionFlow by remember(postViewModel) { mutableStateOf<Flow<String>?>(null) }
DisposableEffect(key1 = context) {
postViewModel.startLocation(context = context)
locationDescriptionFlow = postViewModel.location
onDispose { postViewModel.stopLocation() }
}
locationDescriptionFlow?.let {
val location by it.collectAsStateWithLifecycle(null)
location?.let { DisplayLocationInTitle(geohash = it) } location?.let { DisplayLocationInTitle(geohash = it) }
}
} }
@Composable @Composable
+1
View File
@@ -416,6 +416,7 @@
<string name="follow_list_selection">Follow List</string> <string name="follow_list_selection">Follow List</string>
<string name="follow_list_kind3follows">All Follows</string> <string name="follow_list_kind3follows">All Follows</string>
<string name="follow_list_aroundme">Around Me</string>
<string name="follow_list_global">Global</string> <string name="follow_list_global">Global</string>
<string name="follow_list_mute_list">Mute List</string> <string name="follow_list_mute_list">Mute List</string>
@@ -119,6 +119,8 @@ class ContactListEvent(
const val KIND = 3 const val KIND = 3
const val ALT = "Follow List" const val ALT = "Follow List"
fun blockListFor(pubKeyHex: HexKey): String = "3:$pubKeyHex:"
fun createFromScratch( fun createFromScratch(
followUsers: List<Contact> = emptyList(), followUsers: List<Contact> = emptyList(),
followTags: List<String> = emptyList(), followTags: List<String> = emptyList(),