Compare commits

...

28 Commits

Author SHA1 Message Date
Vitor Pamplona
d73dec8f0b v0.74.5 2023-08-21 18:31:00 -04:00
Vitor Pamplona
c6124376df Merge pull request #552 from greenart7c3/fix-crash
fix crash when getting firebase token without play services
2023-08-21 18:30:02 -04:00
Vitor Pamplona
87c19f4232 BugFix for ClassCastException when the event changes 2023-08-21 18:21:28 -04:00
greenart7c3
a1f116909f fix crash when not using play services 2023-08-21 19:20:14 -03:00
Vitor Pamplona
ca9951f9b8 Fixes the loading of GiftWraps as intents in the app. 2023-08-21 18:19:10 -04:00
Vitor Pamplona
2bedd79e6f Fixing search for root to go backwards in the etag-stack. 2023-08-21 18:17:21 -04:00
Vitor Pamplona
a9af2d8982 v0.74.4 2023-08-21 17:30:37 -04:00
Vitor Pamplona
655a563f61 Moves community description and rules to a single Translateable text 2023-08-21 16:09:22 -04:00
Vitor Pamplona
9777c709a1 - Fixes some of the TopBar padding issues.
- Adds more info tap on single-user chat rooms
- Moves creation date to a new line.
2023-08-21 15:42:45 -04:00
Vitor Pamplona
251f4854fc Copies to Clipboard the GiftWrap ID and not the inner message ID 2023-08-21 14:23:36 -04:00
Vitor Pamplona
43c32f0b0e Moves last scope to a class-bound variable. 2023-08-21 13:51:03 -04:00
Vitor Pamplona
ec3b07147c Avoids creating new corotine scopes 2023-08-21 13:34:18 -04:00
Vitor Pamplona
0e6a2c339e Fixes the update of the feed after adding an image on stories. 2023-08-20 21:24:57 -04:00
Vitor Pamplona
844f7cd478 Reports on the number of observers to the cache. 2023-08-20 21:24:23 -04:00
Vitor Pamplona
a2edc9f3b2 Fixes the divider presence in Live Activities and Notifications 2023-08-20 17:31:36 -04:00
Vitor Pamplona
35d09aee77 - Filter follows in the list of participants
- Uses KIND3 follows for the notification dot in the bottom nav bar
2023-08-20 17:08:24 -04:00
Vitor Pamplona
a79d1d5bd6 Fixes issue with unfollowing hashtag written in a different case 2023-08-20 16:46:44 -04:00
Vitor Pamplona
ba9315fcd1 Moves coroutines of BundledUpdate and BundledInsert to a managed model. 2023-08-20 16:37:49 -04:00
Vitor Pamplona
f5c71b4f90 Broadcasts a note when a person replies to it. 2023-08-20 16:35:34 -04:00
Vitor Pamplona
5a40b39952 v0.74.3 2023-08-20 11:36:00 -04:00
Vitor Pamplona
2ac3b42d4d Correctly gets the Global Relays being used. 2023-08-20 11:33:56 -04:00
Vitor Pamplona
7cfad373f2 Adds Copy URL and Copy Note ID popups for images and videos 2023-08-20 11:18:31 -04:00
Vitor Pamplona
5d061a79e2 Explanation on why the Global and All Follows code have spaces 2023-08-20 10:29:35 -04:00
Vitor Pamplona
eb59a9d505 Keeps track of a message's GiftWrap host in order to broadcast later. 2023-08-20 10:24:11 -04:00
Vitor Pamplona
784f867a8e Updating media controls to the next version 2023-08-20 01:16:08 -04:00
Vitor Pamplona
954a9d66fb Fixes the crash when opening a malformed thread with a wrong root marker 2023-08-20 01:15:46 -04:00
Vitor Pamplona
dfdced3e8c Includes the zaps from the user's own key in the Summary Chart 2023-08-19 14:57:24 -04:00
Vitor Pamplona
0af141c5b7 Fixes generateForegroundServiceDidNotStartInTimeException on Samsung phones 2023-08-19 14:44:39 -04:00
54 changed files with 707 additions and 411 deletions

View File

@@ -13,14 +13,14 @@ android {
applicationId "com.vitorpamplona.amethyst"
minSdk 26
targetSdk 34
versionCode 275
versionName "0.74.2"
versionCode 278
versionName "0.74.5"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary true
}
resourceConfigurations += ['ar', 'cs', 'de', 'eo', 'es', 'fa', 'fr', 'hu', 'night', 'nl', 'pt-rBR', 'ru', 'sv-rSE', 'ta', 'tr', 'uk', 'zh', 'sh-rHK', 'zh-rTW', 'ja']
resourceConfigurations += ['ar', 'cs', 'de', 'eo', 'es', 'fa', 'fr', 'hu', 'ja', 'night', 'nl', 'pt-rBR', 'ru', 'sv-rSE', 'ta', 'tr', 'uk', 'zh', 'sh-rHK', 'zh-rTW']
}
buildTypes {
@@ -111,6 +111,7 @@ dependencies {
implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version"
// Zoomable images
implementation "net.engawapg.lib:zoomable:1.4.3"
// Biometrics
@@ -119,7 +120,7 @@ dependencies {
// Websockets API
implementation 'com.squareup.okhttp3:okhttp:5.0.0-alpha.11'
// link preview
// HTML Parsing for Link Preview
implementation 'org.jsoup:jsoup:1.16.1'
// Encrypted Key Storage
@@ -159,9 +160,9 @@ dependencies {
// Language picker and Theme chooser
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'androidx.media3:media3-exoplayer-hls:1.1.0'
implementation 'androidx.media3:media3-ui:1.1.0'
implementation 'androidx.media3:media3-session:1.1.0'
implementation "androidx.media3:media3-exoplayer-hls:$media3_version"
implementation "androidx.media3:media3-ui:$media3_version"
implementation "androidx.media3:media3-session:$media3_version"
// Local model for language identification
playImplementation 'com.google.mlkit:language-id:17.0.4'

View File

@@ -26,15 +26,35 @@ class PlaybackService : MediaSessionService() {
return ProgressiveMediaSource.Factory(VideoCache.get(Amethyst.instance, HttpClient.getHttpClient()))
}
fun lazyHlsDS(): MultiPlayerPlaybackManager {
managerHls?.let { return it }
val newInstance = MultiPlayerPlaybackManager(newHslDataSource(), videoViewedPositionCache)
managerHls = newInstance
return newInstance
}
fun lazyProgressiveDS(): MultiPlayerPlaybackManager {
managerProgressive?.let { return it }
val newInstance = MultiPlayerPlaybackManager(newProgressiveDataSource(), videoViewedPositionCache)
managerProgressive = newInstance
return newInstance
}
fun lazyLocalDS(): MultiPlayerPlaybackManager {
managerLocal?.let { return it }
val newInstance = MultiPlayerPlaybackManager(cachedPositions = videoViewedPositionCache)
managerLocal = newInstance
return newInstance
}
// Create your Player and MediaSession in the onCreate lifecycle event
@OptIn(UnstableApi::class)
override fun onCreate() {
super.onCreate()
managerHls = MultiPlayerPlaybackManager(newHslDataSource(), videoViewedPositionCache)
managerProgressive = MultiPlayerPlaybackManager(newProgressiveDataSource(), videoViewedPositionCache)
managerLocal = MultiPlayerPlaybackManager(cachedPositions = videoViewedPositionCache)
// Stop all videos and recreates all managers when the proxy changes.
HttpClient.proxyChangeListeners.add(this@PlaybackService::onProxyUpdated)
}
@@ -62,11 +82,11 @@ class PlaybackService : MediaSessionService() {
fun getAppropriateMediaSessionManager(fileName: String): MultiPlayerPlaybackManager? {
return if (fileName.startsWith("file")) {
managerLocal
lazyLocalDS()
} else if (fileName.endsWith("m3u8")) {
managerHls
lazyHlsDS()
} else {
managerProgressive
lazyProgressiveDS()
}
}

View File

@@ -49,8 +49,8 @@ fun getLanguagesSpokenByUser(): Set<String> {
return codedList
}
val GLOBAL_FOLLOWS = " Global "
val KIND3_FOLLOWS = " All Follows "
val GLOBAL_FOLLOWS = " Global " // This has spaces to avoid mixing with a potential NIP-51 list with the same name.
val KIND3_FOLLOWS = " All Follows " // This has spaces to avoid mixing with a potential NIP-51 list with the same name.
@OptIn(DelicateCoroutinesApi::class)
@Stable
@@ -452,7 +452,13 @@ class Account(
fun broadcast(note: Note) {
note.event?.let {
Client.send(it)
if (it is WrappedEvent && it.host != null) {
it.host?.let { hostEvent ->
Client.send(hostEvent)
}
} else {
Client.send(it)
}
}
}
@@ -784,6 +790,23 @@ class Account(
Client.send(signedEvent, relayList = relayList)
LocalCache.consume(signedEvent)
// broadcast replied notes
replyingTo?.let {
LocalCache.getNoteIfExists(replyingTo)?.event?.let {
Client.send(it, relayList = relayList)
}
}
replyTo?.forEach {
it.event?.let {
Client.send(it, relayList = relayList)
}
}
addresses?.forEach {
LocalCache.getAddressableNoteIfExists(it.toTag())?.event?.let {
Client.send(it, relayList = relayList)
}
}
}
fun sendPoll(
@@ -826,6 +849,17 @@ class Account(
// println("Sending new PollNoteEvent: %s".format(signedEvent.toJson()))
Client.send(signedEvent, relayList = relayList)
LocalCache.consume(signedEvent)
replyTo?.forEach {
it.event?.let {
Client.send(it, relayList = relayList)
}
}
addresses?.forEach {
LocalCache.getAddressableNoteIfExists(it.toTag())?.event?.let {
Client.send(it, relayList = relayList)
}
}
}
fun sendChannelMessage(message: String, toChannel: String, replyTo: List<Note>?, mentions: List<User>?, zapReceiver: String? = null, wantsToMarkAsSensitive: Boolean, zapRaiserAmount: Long? = null, geohash: String? = null) {
@@ -1610,8 +1644,8 @@ class Account(
}.toTypedArray()
}
fun convertGlobalRelays(): Array<String> {
return localRelays.filter { it.feedTypes.contains(FeedType.GLOBAL) }
fun activeGlobalRelays(): Array<String> {
return (activeRelays() ?: convertLocalRelays()).filter { it.activeTypes.contains(FeedType.GLOBAL) }
.map { it.url }
.toTypedArray()
}

View File

@@ -72,7 +72,22 @@ open class Note(val idHex: String) {
open fun idNote() = id().toNote()
open fun toNEvent(): String {
return Nip19.createNEvent(idHex, author?.pubkeyHex, event?.kind(), relays.firstOrNull())
val myEvent = event
return if (myEvent is WrappedEvent) {
val host = myEvent.host
if (host != null) {
Nip19.createNEvent(
host.id,
host.pubKey,
host.kind(),
relays.firstOrNull()
)
} else {
Nip19.createNEvent(idHex, author?.pubkeyHex, event?.kind(), relays.firstOrNull())
}
} else {
Nip19.createNEvent(idHex, author?.pubkeyHex, event?.kind(), relays.firstOrNull())
}
}
fun toNostrUri(): String {
@@ -129,12 +144,14 @@ open class Note(val idHex: String) {
return "/" + formattedDateTime(createdAt() ?: 0) + ";"
}
val mySignature = replyTo
.filter { it in eventsToConsider } // This forces the signature to be based on a branch, avoiding two roots
.map {
cachedSignatures[it] ?: it.replyLevelSignature(eventsToConsider, cachedSignatures).apply { cachedSignatures.put(it, this) }
}
.maxBy { it.length }.removeSuffix(";") + "/" + formattedDateTime(createdAt() ?: 0) + ";"
val mySignature = (
replyTo
.filter { it in eventsToConsider } // This forces the signature to be based on a branch, avoiding two roots
.map {
cachedSignatures[it] ?: it.replyLevelSignature(eventsToConsider, cachedSignatures).apply { cachedSignatures.put(it, this) }
}
.maxByOrNull { it.length }?.removeSuffix(";") ?: ""
) + "/" + formattedDateTime(createdAt() ?: 0) + ";"
cachedSignatures[this] = mySignature
return mySignature
@@ -613,6 +630,7 @@ open class Note(val idHex: String) {
fun clearLive() {
if (liveSet != null && liveSet?.isInUse() == false) {
liveSet?.destroy()
liveSet = null
}
}
@@ -654,12 +672,26 @@ class NoteLiveSet(u: Note) {
relays.hasObservers() ||
zaps.hasObservers()
}
fun destroy() {
metadata.destroy()
reactions.destroy()
boosts.destroy()
replies.destroy()
reports.destroy()
relays.destroy()
zaps.destroy()
}
}
class NoteLiveData(val note: Note) : LiveData<NoteState>(NoteState(note)) {
// Refreshes observers in batches.
private val bundler = BundledUpdate(500, Dispatchers.IO)
fun destroy() {
bundler.cancel()
}
fun invalidateData() {
if (!hasObservers()) return

View File

@@ -15,9 +15,14 @@ class ThreadAssembler {
testedNotes.add(note)
val markedAsRoot = note.event?.tags()?.firstOrNull { it[0] == "e" && it.size > 3 && it[3] == "root" }?.getOrNull(1)
if (markedAsRoot != null) return LocalCache.checkGetOrCreateNote(markedAsRoot)
if (markedAsRoot != null) {
// Check to ssee if there is an error in the tag and the root has replies
if (LocalCache.getNoteIfExists(markedAsRoot)?.replyTo?.isEmpty() == true) {
return LocalCache.checkGetOrCreateNote(markedAsRoot)
}
}
val hasNoReplyTo = note.replyTo?.firstOrNull { it.replyTo?.isEmpty() == true }
val hasNoReplyTo = note.replyTo?.reversed()?.firstOrNull { it.replyTo?.isEmpty() == true }
if (hasNoReplyTo != null) return hasNoReplyTo
// recursive

View File

@@ -372,6 +372,7 @@ class User(val pubkeyHex: String) {
fun clearLive() {
if (liveSet != null && liveSet?.isInUse() == false) {
liveSet?.destroy()
liveSet = null
}
}
@@ -400,6 +401,18 @@ class UserLiveSet(u: User) {
zaps.hasObservers() ||
bookmarks.hasObservers()
}
fun destroy() {
follows.destroy()
followers.destroy()
reports.destroy()
messages.destroy()
relays.destroy()
relayInfo.destroy()
metadata.destroy()
zaps.destroy()
bookmarks.destroy()
}
}
@Immutable
@@ -470,6 +483,10 @@ class UserLiveData(val user: User) : LiveData<UserState>(UserState(user)) {
// Refreshes observers in batches.
private val bundler = BundledUpdate(500, Dispatchers.IO)
fun destroy() {
bundler.cancel()
}
fun invalidateData() {
if (!hasObservers()) return
checkNotInMainThread()

View File

@@ -45,7 +45,7 @@ class CashuProcessor {
}
}
fun melt(token: CashuToken, lud16: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
suspend fun melt(token: CashuToken, lud16: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
checkNotInMainThread()
runCatching {

View File

@@ -2,10 +2,7 @@ package com.vitorpamplona.amethyst.service
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.vitorpamplona.amethyst.BuildConfig
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.Call
import okhttp3.Callback
@@ -26,55 +23,46 @@ class Nip05Verifier() {
return null
}
fun fetchNip05Json(nip05address: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
val scope = CoroutineScope(Job() + Dispatchers.IO)
scope.launch {
fetchNip05JsonSuspend(nip05address, onSuccess, onError)
}
}
private suspend fun fetchNip05JsonSuspend(nip05: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
suspend fun fetchNip05Json(nip05: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) = withContext(Dispatchers.IO) {
checkNotInMainThread()
val url = assembleUrl(nip05)
if (url == null) {
onError("Could not assemble url from Nip05: \"${nip05}\". Check the user's setup")
return
return@withContext
}
withContext(Dispatchers.IO) {
try {
val request = Request.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(url)
.build()
try {
val request = Request.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(url)
.build()
HttpClient.getHttpClient().newCall(request).enqueue(object : Callback {
override fun onResponse(call: Call, response: Response) {
checkNotInMainThread()
HttpClient.getHttpClient().newCall(request).enqueue(object : Callback {
override fun onResponse(call: Call, response: Response) {
checkNotInMainThread()
response.use {
if (it.isSuccessful) {
onSuccess(it.body.string())
} else {
onError("Could not resolve $nip05. Error: ${it.code}. Check if the server up and if the address $nip05 is correct")
}
response.use {
if (it.isSuccessful) {
onSuccess(it.body.string())
} else {
onError("Could not resolve $nip05. Error: ${it.code}. Check if the server up and if the address $nip05 is correct")
}
}
}
override fun onFailure(call: Call, e: java.io.IOException) {
onError("Could not resolve $url. Check if the server up and if the address $nip05 is correct")
e.printStackTrace()
}
})
} catch (e: java.lang.Exception) {
onError("Could not resolve '$url': ${e.message}")
}
override fun onFailure(call: Call, e: java.io.IOException) {
onError("Could not resolve $url. Check if the server up and if the address $nip05 is correct")
e.printStackTrace()
}
})
} catch (e: java.lang.Exception) {
onError("Could not resolve '$url': ${e.message}")
}
}
fun verifyNip05(nip05: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
suspend fun verifyNip05(nip05: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
// check fails on tests
checkNotInMainThread()

View File

@@ -10,12 +10,15 @@ import com.vitorpamplona.quartz.events.Event
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import java.util.UUID
import kotlin.Error
abstract class NostrDataSource(val debugName: String) {
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private var subscriptions = mapOf<String, Subscription>()
data class Counter(var counter: Int)
@@ -76,6 +79,8 @@ abstract class NostrDataSource(val debugName: String) {
fun destroy() {
stop()
Client.unsubscribe(clientListener)
scope.cancel()
bundler.cancel()
}
open fun start() {
@@ -116,8 +121,7 @@ abstract class NostrDataSource(val debugName: String) {
}
fun resetFilters() {
val scope = CoroutineScope(Job() + Dispatchers.IO)
scope.launch {
scope.launch(Dispatchers.IO) {
resetFiltersSuspend()
}
}

View File

@@ -7,10 +7,7 @@ import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.quartz.encoders.Bech32
import com.vitorpamplona.quartz.encoders.LnInvoiceUtil
import com.vitorpamplona.quartz.encoders.toLnUrl
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.Call
import okhttp3.Callback
@@ -40,96 +37,70 @@ class LightningAddressResolver() {
return null
}
fun fetchLightningAddressJson(lnaddress: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
val scope = CoroutineScope(Job() + Dispatchers.IO)
scope.launch {
fetchLightningAddressJsonSuspend(lnaddress, onSuccess, onError)
}
}
private suspend fun fetchLightningAddressJsonSuspend(lnaddress: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
private suspend fun fetchLightningAddressJson(lnaddress: String, onSuccess: suspend (String) -> Unit, onError: (String) -> Unit) = withContext(Dispatchers.IO) {
checkNotInMainThread()
val url = assembleUrl(lnaddress)
if (url == null) {
onError("Could not assemble LNUrl from Lightning Address \"${lnaddress}\". Check the user's setup")
return
return@withContext
}
try {
withContext(Dispatchers.IO) {
val request: Request = Request.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(url)
.build()
client.newCall(request).enqueue(object : Callback {
override fun onResponse(call: Call, response: Response) {
response.use {
if (it.isSuccessful) {
onSuccess(it.body.string())
} else {
onError("Could not resolve $lnaddress. Error: ${it.code}. Check if the server up and if the lightning address $lnaddress is correct")
}
}
}
override fun onFailure(call: Call, e: java.io.IOException) {
onError("Could not resolve $url. Check if the server up and if the lightning address $lnaddress is correct")
e.printStackTrace()
}
})
}
} catch (e: Exception) {
onError("Could not resolve $url. Check if the server up and if the lightning address $lnaddress is correct")
}
}
fun fetchLightningInvoice(lnCallback: String, milliSats: Long, message: String, nostrRequest: String? = null, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
val scope = CoroutineScope(Job() + Dispatchers.IO)
scope.launch {
fetchLightningInvoiceSuspend(lnCallback, milliSats, message, nostrRequest, onSuccess, onError)
}
}
private suspend fun fetchLightningInvoiceSuspend(lnCallback: String, milliSats: Long, message: String, nostrRequest: String? = null, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
withContext(Dispatchers.IO) {
val encodedMessage = URLEncoder.encode(message, "utf-8")
val urlBinder = if (lnCallback.contains("?")) "&" else "?"
var url = "$lnCallback${urlBinder}amount=$milliSats&comment=$encodedMessage"
if (nostrRequest != null) {
val encodedNostrRequest = URLEncoder.encode(nostrRequest, "utf-8")
url += "&nostr=$encodedNostrRequest"
}
val request: Request = Request.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(url)
.build()
client.newCall(request).enqueue(object : Callback {
override fun onResponse(call: Call, response: Response) {
response.use {
if (it.isSuccessful) {
onSuccess(response.body.string())
} else {
onError("Could not fetch invoice from $lnCallback")
}
}
client.newCall(request).execute().use {
if (it.isSuccessful) {
onSuccess(it.body.string())
} else {
onError("Could not resolve $lnaddress. Error: ${it.code}. Check if the server up and if the lightning address $lnaddress is correct")
}
override fun onFailure(call: Call, e: java.io.IOException) {
onError("Could not fetch an invoice from $lnCallback. Message ${e.message}")
e.printStackTrace()
}
})
}
} catch (e: Exception) {
e.printStackTrace()
onError("Could not resolve $url. Check if the server up and if the lightning address $lnaddress is correct")
}
}
fun lnAddressToLnUrl(lnaddress: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
suspend fun fetchLightningInvoice(lnCallback: String, milliSats: Long, message: String, nostrRequest: String? = null, onSuccess: (String) -> Unit, onError: (String) -> Unit) = withContext(Dispatchers.IO) {
val encodedMessage = URLEncoder.encode(message, "utf-8")
val urlBinder = if (lnCallback.contains("?")) "&" else "?"
var url = "$lnCallback${urlBinder}amount=$milliSats&comment=$encodedMessage"
if (nostrRequest != null) {
val encodedNostrRequest = URLEncoder.encode(nostrRequest, "utf-8")
url += "&nostr=$encodedNostrRequest"
}
val request: Request = Request.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(url)
.build()
client.newCall(request).enqueue(object : Callback {
override fun onResponse(call: Call, response: Response) {
response.use {
if (it.isSuccessful) {
onSuccess(response.body.string())
} else {
onError("Could not fetch invoice from $lnCallback")
}
}
}
override fun onFailure(call: Call, e: java.io.IOException) {
onError("Could not fetch an invoice from $lnCallback. Message ${e.message}")
e.printStackTrace()
}
})
}
suspend fun lnAddressToLnUrl(lnaddress: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
fetchLightningAddressJson(
lnaddress,
onSuccess = {
@@ -139,7 +110,15 @@ class LightningAddressResolver() {
)
}
fun lnAddressInvoice(lnaddress: String, milliSats: Long, message: String, nostrRequest: String? = null, onSuccess: (String) -> Unit, onError: (String) -> Unit, onProgress: (percent: Float) -> Unit) {
suspend fun lnAddressInvoice(
lnaddress: String,
milliSats: Long,
message: String,
nostrRequest: String? = null,
onSuccess: (String) -> Unit,
onError: (String) -> Unit,
onProgress: (percent: Float) -> Unit
) {
val mapper = jacksonObjectMapper()
fetchLightningAddressJson(

View File

@@ -20,26 +20,20 @@ import com.vitorpamplona.quartz.events.LnZapRequestEvent
import com.vitorpamplona.quartz.events.PrivateDmEvent
import com.vitorpamplona.quartz.events.SealedGossipEvent
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
class EventNotificationConsumer(private val applicationContext: Context) {
fun consume(event: Event) {
val scope = CoroutineScope(Job() + Dispatchers.IO)
scope.launch {
if (LocalCache.notes[event.id] == null) {
if (LocalCache.justVerify(event)) {
LocalCache.justConsume(event, null)
val manager = notificationManager()
if (manager.areNotificationsEnabled()) {
when (event) {
is PrivateDmEvent -> notify(event)
is LnZapEvent -> notify(event)
is GiftWrapEvent -> unwrapAndNotify(event)
}
fun consume(event: Event) {
if (LocalCache.notes[event.id] == null) {
if (LocalCache.justVerify(event)) {
LocalCache.justConsume(event, null)
val manager = notificationManager()
if (manager.areNotificationsEnabled()) {
when (event) {
is PrivateDmEvent -> notify(event)
is LnZapEvent -> notify(event)
is GiftWrapEvent -> unwrapAndNotify(event)
}
}
}

View File

@@ -6,10 +6,8 @@ import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.service.HttpClient
import com.vitorpamplona.quartz.events.RelayAuthEvent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
@@ -64,12 +62,9 @@ class RegisterAccounts(
}
}
fun go(notificationToken: String) {
val scope = CoroutineScope(Job() + Dispatchers.IO)
scope.launch {
postRegistrationEvent(
signEventsToProveControlOfAccounts(accounts, notificationToken)
)
}
suspend fun go(notificationToken: String) = withContext(Dispatchers.IO) {
postRegistrationEvent(
signEventsToProveControlOfAccounts(accounts, notificationToken)
)
}
}

View File

@@ -4,18 +4,11 @@ import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.quartz.events.Event
import com.vitorpamplona.quartz.events.EventInterface
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
/**
* RelayPool manages the connection to multiple Relays and lets consumers deal with simple events.
*/
object RelayPool : Relay.Listener {
val scope = CoroutineScope(Job() + Dispatchers.IO)
private var relays = listOf<Relay>()
private var listeners = setOf<Listener>()
@@ -136,9 +129,7 @@ object RelayPool : Relay.Listener {
val live: RelayPoolLiveData = RelayPoolLiveData(this)
private fun refreshObservers() {
scope.launch {
live.refresh()
}
live.refresh()
}
}

View File

@@ -97,7 +97,9 @@ class MainActivity : AppCompatActivity() {
ServiceManager.start(this@MainActivity)
}
PushNotificationUtils().init(LocalPreferences.allSavedAccounts())
GlobalScope.launch(Dispatchers.IO) {
PushNotificationUtils().init(LocalPreferences.allSavedAccounts())
}
}
override fun onPause() {

View File

@@ -110,7 +110,6 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun NewPostView(
onClose: () -> Unit,

View File

@@ -48,7 +48,7 @@ import kotlinx.coroutines.withContext
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun NewImageButton(accountViewModel: AccountViewModel, nav: (String) -> Unit) {
fun NewImageButton(accountViewModel: AccountViewModel, nav: (String) -> Unit, navScrollToTop: (Route, Boolean) -> Unit) {
var wantsToPost by remember {
mutableStateOf(false)
}
@@ -62,11 +62,9 @@ fun NewImageButton(accountViewModel: AccountViewModel, nav: (String) -> Unit) {
val postViewModel: NewMediaModel = viewModel()
postViewModel.onceUploaded {
scope.launch(Dispatchers.Default) {
// awaits an refresh on the list
delay(250)
delay(500)
withContext(Dispatchers.Main) {
val route = Route.Video.route.replace("{scrollToTop}", "true")
nav(route)
navScrollToTop(Route.Video, true)
}
}
}

View File

@@ -5,8 +5,9 @@ import com.vitorpamplona.amethyst.service.checkNotInMainThread
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -21,6 +22,8 @@ class BundledUpdate(
val delay: Long,
val dispatcher: CoroutineDispatcher = Dispatchers.Default
) {
val scope = CoroutineScope(dispatcher + SupervisorJob())
private var onlyOneInBlock = AtomicBoolean()
private var invalidatesAgain = false
@@ -32,7 +35,6 @@ class BundledUpdate(
return
}
val scope = CoroutineScope(Job() + dispatcher)
scope.launch {
try {
onUpdate()
@@ -48,6 +50,10 @@ class BundledUpdate(
}
}
}
fun cancel() {
scope.cancel()
}
}
/**
@@ -58,6 +64,8 @@ class BundledInsert<T>(
val delay: Long,
val dispatcher: CoroutineDispatcher = Dispatchers.Default
) {
val scope = CoroutineScope(dispatcher + SupervisorJob())
private var onlyOneInBlock = AtomicBoolean()
private var queue = LinkedBlockingQueue<T>()
@@ -69,7 +77,6 @@ class BundledInsert<T>(
return
}
val scope = CoroutineScope(Job() + dispatcher)
scope.launch(Dispatchers.IO) {
try {
val mySet = mutableSetOf<T>()
@@ -88,4 +95,8 @@ class BundledInsert<T>(
}
}
}
fun cancel() {
scope.cancel()
}
}

View File

@@ -39,6 +39,7 @@ import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.theme.subtleBorder
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@Composable
@@ -152,23 +153,25 @@ fun InvoiceRequest(
Button(
modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp),
onClick = {
val zapRequest = account.createZapRequestFor(toUserPubKeyHex, message, account.defaultZapType)
scope.launch(Dispatchers.IO) {
val zapRequest = account.createZapRequestFor(toUserPubKeyHex, message, account.defaultZapType)
LightningAddressResolver().lnAddressInvoice(
lud16,
amount * 1000,
message,
zapRequest?.toJson(),
onSuccess = onSuccess,
onError = {
scope.launch {
Toast.makeText(context, it, Toast.LENGTH_SHORT).show()
onClose()
LightningAddressResolver().lnAddressInvoice(
lud16,
amount * 1000,
message,
zapRequest?.toJson(),
onSuccess = onSuccess,
onError = {
scope.launch {
Toast.makeText(context, it, Toast.LENGTH_SHORT).show()
onClose()
}
},
onProgress = {
}
},
onProgress = {
}
)
)
}
},
shape = QuoteBorder,
colors = ButtonDefaults.buttonColors(

View File

@@ -29,11 +29,18 @@ import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.text.InlineTextContent
import androidx.compose.foundation.text.appendInlineContent
import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.DropdownMenu
import androidx.compose.material.DropdownMenuItem
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Share
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.Immutable
@@ -46,10 +53,12 @@ import androidx.compose.runtime.rememberCoroutineScope
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.layout.ContentScale
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.Placeholder
import androidx.compose.ui.text.PlaceholderVerticalAlign
@@ -77,11 +86,15 @@ import com.vitorpamplona.amethyst.ui.note.DownloadForOfflineIcon
import com.vitorpamplona.amethyst.ui.note.HashCheckFailedIcon
import com.vitorpamplona.amethyst.ui.note.HashCheckIcon
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.ui.theme.Font17SP
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.Size20dp
import com.vitorpamplona.amethyst.ui.theme.Size24dp
import com.vitorpamplona.amethyst.ui.theme.Size30dp
import com.vitorpamplona.amethyst.ui.theme.Size5dp
import com.vitorpamplona.amethyst.ui.theme.imageModifier
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.crypto.CryptoUtils
import com.vitorpamplona.quartz.encoders.toHexKey
import kotlinx.collections.immutable.ImmutableList
@@ -184,13 +197,20 @@ fun ZoomableContentView(
roundedCorner: Boolean,
accountViewModel: AccountViewModel
) {
val clipboardManager = LocalClipboardManager.current
// store the dialog open or close state
var dialogOpen by remember {
mutableStateOf(false)
}
// store the dialog open or close state
val shareOpen = remember {
mutableStateOf(false)
}
if (shareOpen.value) {
ShareImageAction(shareOpen, content) { shareOpen.value = false }
}
var mainImageModifier = if (roundedCorner) {
MaterialTheme.colors.imageModifier
} else {
@@ -200,12 +220,12 @@ fun ZoomableContentView(
if (content is ZoomableUrlContent) {
mainImageModifier = mainImageModifier.combinedClickable(
onClick = { dialogOpen = true },
onLongClick = { clipboardManager.setText(AnnotatedString(content.uri ?: content.url)) }
onLongClick = { shareOpen.value = true }
)
} else if (content is ZoomablePreloadedContent) {
mainImageModifier = mainImageModifier.combinedClickable(
onClick = { dialogOpen = true },
onLongClick = { clipboardManager.setText(AnnotatedString(content.uri)) }
onLongClick = { shareOpen.value = true }
)
} else {
mainImageModifier = mainImageModifier.clickable {
@@ -649,7 +669,10 @@ fun ZoomableImageDialog(
val myContent = allImages[pagerState.currentPage]
if (myContent is ZoomableUrlContent) {
SaveToGallery(url = myContent.url)
Row() {
CopyToClipboard(content = myContent)
SaveToGallery(url = myContent.url)
}
} else if (myContent is ZoomableLocalImage && myContent.localFile != null) {
SaveToGallery(
localFile = myContent.localFile,
@@ -662,6 +685,64 @@ fun ZoomableImageDialog(
}
}
@Composable
private fun CopyToClipboard(
content: ZoomableContent
) {
val popupExpanded = remember { mutableStateOf(false) }
Button(
modifier = Modifier.padding(horizontal = Size5dp),
onClick = { popupExpanded.value = true },
shape = ButtonBorder,
colors = ButtonDefaults
.buttonColors(
backgroundColor = MaterialTheme.colors.placeholderText
)
) {
Icon(
tint = Color.White,
imageVector = Icons.Default.Share,
modifier = Size20Modifier,
contentDescription = stringResource(R.string.copy_url_to_clipboard)
)
ShareImageAction(popupExpanded, content) { popupExpanded.value = false }
}
}
@Composable
private fun ShareImageAction(
popupExpanded:
MutableState<Boolean>,
content: ZoomableContent,
onDismiss: () -> Unit
) {
DropdownMenu(
expanded = popupExpanded.value,
onDismissRequest = onDismiss
) {
val clipboardManager = LocalClipboardManager.current
if (content is ZoomableUrlContent) {
DropdownMenuItem(onClick = { clipboardManager.setText(AnnotatedString(content.url)); onDismiss() }) {
Text(stringResource(R.string.copy_url_to_clipboard))
}
if (content.uri != null) {
DropdownMenuItem(onClick = { clipboardManager.setText(AnnotatedString(content.uri)); onDismiss() }) {
Text(stringResource(R.string.copy_the_note_id_to_the_clipboard))
}
}
}
if (content is ZoomablePreloadedContent) {
DropdownMenuItem(onClick = { clipboardManager.setText(AnnotatedString(content.uri)); onDismiss() }) {
Text(stringResource(R.string.copy_the_note_id_to_the_clipboard))
}
}
}
}
@Composable
private fun RenderImageOrVideo(content: ZoomableContent, roundedCorner: Boolean, accountViewModel: AccountViewModel) {
val mainModifier = Modifier

View File

@@ -11,13 +11,19 @@ import com.vitorpamplona.quartz.events.LiveActivitiesEvent.Companion.STATUS_LIVE
import com.vitorpamplona.quartz.events.LiveActivitiesEvent.Companion.STATUS_PLANNED
import com.vitorpamplona.quartz.utils.TimeUtils
open class DiscoverLiveFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
open class DiscoverLiveFeedFilter(
val account: Account
) : AdditiveFeedFilter<Note>() {
override fun feedKey(): String {
return account.userProfile().pubkeyHex + "-" + account.defaultDiscoveryFollowList
return account.userProfile().pubkeyHex + "-" + followList()
}
open fun followList(): String {
return account.defaultDiscoveryFollowList
}
override fun showHiddenKey(): Boolean {
return account.defaultDiscoveryFollowList == PeopleListEvent.blockList
return followList() == PeopleListEvent.blockList
}
override fun feed(): List<Note> {
@@ -39,15 +45,15 @@ open class DiscoverLiveFeedFilter(val account: Account) : AdditiveFeedFilter<Not
val isGlobal = account.defaultDiscoveryFollowList == GLOBAL_FOLLOWS
val isHiddenList = showHiddenKey()
val followingKeySet = account.selectedUsersFollowList(account.defaultDiscoveryFollowList) ?: emptySet()
val followingTagSet = account.selectedTagsFollowList(account.defaultDiscoveryFollowList) ?: emptySet()
val followingGeohashSet = account.selectedGeohashesFollowList(account.defaultDiscoveryFollowList) ?: emptySet()
val followingKeySet = account.selectedUsersFollowList(followList()) ?: emptySet()
val followingTagSet = account.selectedTagsFollowList(followList()) ?: emptySet()
val followingGeohashSet = account.selectedGeohashesFollowList(followList()) ?: emptySet()
val activities = collection
.asSequence()
.filter { it.event is LiveActivitiesEvent }
.filter {
isGlobal || it.author?.pubkeyHex in followingKeySet || it.event?.isTaggedHashes(
isGlobal || (it.event as LiveActivitiesEvent).participantsIntersect(followingKeySet) || it.event?.isTaggedHashes(
followingTagSet
) == true || it.event?.isTaggedGeoHashes(
followingGeohashSet
@@ -61,7 +67,7 @@ open class DiscoverLiveFeedFilter(val account: Account) : AdditiveFeedFilter<Not
}
override fun sort(collection: Set<Note>): List<Note> {
val followingKeySet = account.selectedUsersFollowList(account.defaultDiscoveryFollowList)
val followingKeySet = account.selectedUsersFollowList(followList())
val counter = ParticipantListBuilder()
val participantCounts = collection.associate {

View File

@@ -1,12 +1,26 @@
package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
import com.vitorpamplona.amethyst.model.KIND3_FOLLOWS
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.OnlineChecker
import com.vitorpamplona.quartz.events.*
import com.vitorpamplona.quartz.events.LiveActivitiesEvent.Companion.STATUS_LIVE
class DiscoverLiveNowFeedFilter(account: Account) : DiscoverLiveFeedFilter(account) {
class DiscoverLiveNowFeedFilter(
account: Account
) : DiscoverLiveFeedFilter(account) {
override fun followList(): String {
// uses follows by default, but other lists if they were selected in the top bar
val currentList = super.followList()
return if (currentList == GLOBAL_FOLLOWS) {
KIND3_FOLLOWS
} else {
currentList
}
}
override fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
val allItems = super.innerApplyFilter(collection)

View File

@@ -38,7 +38,7 @@ class HomeNewThreadFeedFilter(val account: Account) : AdditiveFeedFilter<Note>()
private fun innerApplyFilter(collection: Collection<Note>, ignoreAddressables: Boolean): Set<Note> {
val isGlobal = account.defaultHomeFollowList == GLOBAL_FOLLOWS
val gRelays = account.convertGlobalRelays()
val gRelays = account.activeGlobalRelays()
val isHiddenList = showHiddenKey()
val followingKeySet = account.selectedUsersFollowList(account.defaultHomeFollowList) ?: emptySet()

View File

@@ -15,6 +15,8 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.AppBarDefaults
import androidx.compose.material.ContentAlpha
import androidx.compose.material.Divider
@@ -91,6 +93,7 @@ import com.vitorpamplona.amethyst.ui.note.LongCommunityHeader
import com.vitorpamplona.amethyst.ui.note.NonClickableUserPictures
import com.vitorpamplona.amethyst.ui.note.SearchIcon
import com.vitorpamplona.amethyst.ui.note.ShortCommunityHeader
import com.vitorpamplona.amethyst.ui.note.UserCompose
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.screen.equalImmutableLists
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -107,8 +110,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.ShowVideoStreaming
import com.vitorpamplona.amethyst.ui.screen.loggedIn.SpinnerSelectionDialog
import com.vitorpamplona.amethyst.ui.theme.BottomTopHeight
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.HalfVertSpacer
import com.vitorpamplona.amethyst.ui.theme.HeaderPictureModifier
import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.Size22Modifier
import com.vitorpamplona.amethyst.ui.theme.Size34dp
import com.vitorpamplona.amethyst.ui.theme.Size40dp
@@ -118,7 +121,6 @@ import com.vitorpamplona.quartz.events.PeopleListEvent
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
@@ -232,7 +234,9 @@ private fun CommunityTopBar(
ShortCommunityHeader(baseNote, fontWeight = FontWeight.Medium, accountViewModel, nav)
},
extendableRow = {
LongCommunityHeader(baseNote, accountViewModel, nav)
Column(Modifier.verticalScroll(rememberScrollState())) {
LongCommunityHeader(baseNote = baseNote, accountViewModel = accountViewModel, nav = nav)
}
},
popBack = navPopBack
)
@@ -298,6 +302,17 @@ private fun RenderRoomTopBar(
}
}
},
extendableRow = {
LoadUser(baseUserHex = room.users.first()) {
if (it != null) {
UserCompose(
baseUser = it,
accountViewModel = accountViewModel,
nav = nav
)
}
}
},
popBack = navPopBack
)
} else {
@@ -309,10 +324,17 @@ private fun RenderRoomTopBar(
size = Size34dp
)
RoomNameOnlyDisplay(room, Modifier.padding(start = 10.dp).weight(1f), fontWeight = FontWeight.Medium, accountViewModel.userProfile())
RoomNameOnlyDisplay(
room,
Modifier
.padding(start = 10.dp)
.weight(1f),
fontWeight = FontWeight.Medium,
accountViewModel.userProfile()
)
},
extendableRow = {
LongRoomHeader(room, accountViewModel, nav)
LongRoomHeader(room = room, accountViewModel = accountViewModel, nav = nav)
},
popBack = navPopBack
)
@@ -343,7 +365,7 @@ private fun ChannelTopBar(
)
},
extendableRow = {
LongChannelHeader(baseChannel, accountViewModel, nav)
LongChannelHeader(baseChannel = baseChannel, accountViewModel = accountViewModel, nav = nav)
},
popBack = navPopBack
)
@@ -536,8 +558,7 @@ class FollowListViewModel(val account: Account) : ViewModel() {
val followLists = _followLists.asStateFlow()
fun refresh() {
val scope = CoroutineScope(Job() + Dispatchers.Default)
scope.launch {
viewModelScope.launch(Dispatchers.Default) {
refreshFollows()
}
}
@@ -682,6 +703,7 @@ fun FlexibleTopBarWithBackButton(
},
actions = {}
)
Spacer(modifier = HalfVertSpacer)
Divider(thickness = 0.25.dp)
}
}
@@ -732,9 +754,9 @@ fun debugState(context: Context) {
Log.d("STATE DUMP", "Image Disk Cache ${(imageLoader.diskCache?.size ?: 0) / (1024 * 1024)}/${(imageLoader.diskCache?.maxSize ?: 0) / (1024 * 1024)} MB")
Log.d("STATE DUMP", "Image Memory Cache ${(imageLoader.memoryCache?.size ?: 0) / (1024 * 1024)}/${(imageLoader.memoryCache?.maxSize ?: 0) / (1024 * 1024)} MB")
Log.d("STATE DUMP", "Notes: " + LocalCache.notes.filter { it.value.event != null }.size + "/" + LocalCache.notes.size)
Log.d("STATE DUMP", "Addressables: " + LocalCache.addressables.filter { it.value.event != null }.size + "/" + LocalCache.addressables.size)
Log.d("STATE DUMP", "Users: " + LocalCache.users.filter { it.value.info?.latestMetadata != null }.size + "/" + LocalCache.users.size)
Log.d("STATE DUMP", "Notes: " + LocalCache.notes.filter { it.value.liveSet != null }.size + " / " + LocalCache.notes.filter { it.value.event != null }.size + "/" + LocalCache.notes.size)
Log.d("STATE DUMP", "Addressables: " + LocalCache.addressables.filter { it.value.liveSet != null }.size + " / " + LocalCache.addressables.filter { it.value.event != null }.size + "/" + LocalCache.addressables.size)
Log.d("STATE DUMP", "Users: " + LocalCache.users.filter { it.value.liveSet != null }.size + " / " + LocalCache.users.filter { it.value.info?.latestMetadata != null }.size + "/" + LocalCache.users.size)
Log.d("STATE DUMP", "Memory used by Events: " + LocalCache.notes.values.sumOf { it.event?.countMemory() ?: 0 } / (1024 * 1024) + " MB")
@@ -863,9 +885,7 @@ fun MyExtensibleTopAppBar(
if (expanded.value && extendableRow != null) {
Row(
Modifier
.fillMaxWidth()
.padding(bottom = Size10dp),
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Start,
verticalAlignment = Alignment.CenterVertically
) {

View File

@@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.Divider
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
@@ -58,6 +59,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.EndedFlag
import com.vitorpamplona.amethyst.ui.screen.loggedIn.LiveFlag
import com.vitorpamplona.amethyst.ui.screen.loggedIn.OfflineFlag
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ScheduledFlag
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
import com.vitorpamplona.amethyst.ui.theme.Size35dp
@@ -361,6 +363,10 @@ fun InnerChannelCardWithReactions(
)
}
}
Divider(
thickness = DividerThickness
)
}
@Composable

View File

@@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.Divider
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -25,6 +26,7 @@ import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.ui.screen.MessageSetCard
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.newItemBackgroundColor
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -118,5 +120,9 @@ fun MessageSetCompose(messageSetCard: MessageSetCard, routeForLastRead: String,
NoteDropDownMenu(baseNote, popupExpanded, accountViewModel)
}
}
Divider(
thickness = DividerThickness
)
}
}

View File

@@ -17,6 +17,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.Divider
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
@@ -54,6 +55,7 @@ import com.vitorpamplona.amethyst.ui.screen.CombinedZap
import com.vitorpamplona.amethyst.ui.screen.MultiSetCard
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.showAmountAxis
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.NotificationIconModifier
import com.vitorpamplona.amethyst.ui.theme.NotificationIconModifierSmaller
import com.vitorpamplona.amethyst.ui.theme.Size10dp
@@ -159,6 +161,10 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, showHi
NoteDropDownMenu(baseNote, popupExpanded, accountViewModel)
}
Divider(
thickness = DividerThickness
)
}
}

View File

@@ -23,9 +23,11 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.CutCornerShape
import androidx.compose.foundation.text.ClickableText
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.Divider
@@ -113,6 +115,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChannelHeader
import com.vitorpamplona.amethyst.ui.screen.loggedIn.JoinCommunityButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.LeaveCommunityButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.LiveFlag
import com.vitorpamplona.amethyst.ui.screen.loggedIn.NormalTimeAgo
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ScheduledFlag
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
@@ -124,6 +127,7 @@ import com.vitorpamplona.amethyst.ui.theme.HalfStartPadding
import com.vitorpamplona.amethyst.ui.theme.HalfVertSpacer
import com.vitorpamplona.amethyst.ui.theme.HeaderPictureModifier
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.Size15Modifier
import com.vitorpamplona.amethyst.ui.theme.Size16Modifier
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
@@ -202,7 +206,6 @@ import java.io.File
import java.math.BigDecimal
import java.net.URL
import java.util.Locale
import kotlin.time.ExperimentalTime
import kotlin.time.measureTimedValue
@OptIn(ExperimentalFoundationApi::class)
@@ -518,7 +521,7 @@ fun CommunityHeader(
Column(Modifier.fillMaxWidth()) {
Column(
verticalArrangement = Arrangement.Center,
modifier = modifier.clickable {
modifier = Modifier.clickable {
if (sendToCommunity) {
routeFor(baseNote, accountViewModel.userProfile())?.let {
nav(it)
@@ -535,7 +538,14 @@ fun CommunityHeader(
)
if (expanded.value) {
LongCommunityHeader(baseNote, accountViewModel, nav)
Column(Modifier.verticalScroll(rememberScrollState())) {
LongCommunityHeader(
baseNote = baseNote,
lineModifier = modifier,
accountViewModel = accountViewModel,
nav = nav
)
}
}
}
@@ -548,23 +558,41 @@ fun CommunityHeader(
}
@Composable
fun LongCommunityHeader(baseNote: AddressableNote, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
fun LongCommunityHeader(
baseNote: AddressableNote,
lineModifier: Modifier = Modifier.padding(horizontal = Size10dp, vertical = Size5dp),
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val noteState by baseNote.live().metadata.observeAsState()
val noteEvent = remember(noteState) { noteState?.note?.event as? CommunityDefinitionEvent } ?: return
Row(
Modifier
.fillMaxWidth()
.padding(top = 10.dp)
lineModifier
) {
val rulesLabel = stringResource(id = R.string.rules)
val summary = remember(noteState) {
noteEvent.description()?.ifBlank { null }
val subject = noteEvent.subject()?.ifEmpty { null }
val body = noteEvent.description()?.ifBlank { null }
val rules = noteEvent.rules()?.ifBlank { null }
if (!subject.isNullOrBlank() && body?.split("\n")?.get(0)?.contains(subject) == false) {
if (rules == null) {
"## $subject\n$body"
} else {
"## $subject\n$body\n\n## $rulesLabel\n\n$rules"
}
} else {
if (rules == null) {
body
} else {
"$body\n\n$rulesLabel\n$rules"
}
}
}
Column(
Modifier
.weight(1f)
.padding(start = 10.dp)
Modifier.weight(1f)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
val defaultBackground = MaterialTheme.colors.background
@@ -594,40 +622,8 @@ fun LongCommunityHeader(baseNote: AddressableNote, accountViewModel: AccountView
}
}
val rules = remember(noteState) {
noteEvent.rules()?.ifBlank { null }
}
rules?.let {
Spacer(DoubleVertSpacer)
Row(
Modifier
.fillMaxWidth()
.padding(start = 10.dp)
) {
val defaultBackground = MaterialTheme.colors.background
val background = remember {
mutableStateOf(defaultBackground)
}
val tags = remember(noteEvent) { noteEvent?.tags()?.toImmutableListOfLists() ?: ImmutableListOfLists() }
TranslatableRichTextViewer(
content = it,
canPreview = false,
tags = tags,
backgroundColor = background,
accountViewModel = accountViewModel,
nav = nav
)
}
}
Spacer(DoubleVertSpacer)
Row(
Modifier
.fillMaxWidth()
.padding(start = 10.dp),
lineModifier,
verticalAlignment = Alignment.CenterVertically
) {
Text(
@@ -640,8 +636,6 @@ fun LongCommunityHeader(baseNote: AddressableNote, accountViewModel: AccountView
NoteAuthorPicture(baseNote, nav, accountViewModel, Size25dp)
Spacer(DoubleHorzSpacer)
NoteUsernameDisplay(baseNote, remember { Modifier.weight(1f) })
TimeAgo(baseNote)
MoreOptionsButton(baseNote, accountViewModel)
}
var participantUsers by remember(baseNote) {
@@ -665,12 +659,9 @@ fun LongCommunityHeader(baseNote: AddressableNote, accountViewModel: AccountView
participantUsers.forEach {
Row(
Modifier
.fillMaxWidth()
.padding(start = 10.dp, top = 10.dp)
.clickable {
nav("User/${it.second.pubkeyHex}")
},
lineModifier.clickable {
nav("User/${it.second.pubkeyHex}")
},
verticalAlignment = Alignment.CenterVertically
) {
it.first.role?.let { it1 ->
@@ -687,6 +678,21 @@ fun LongCommunityHeader(baseNote: AddressableNote, accountViewModel: AccountView
UsernameDisplay(it.second, remember { Modifier.weight(1f) })
}
}
Row(
lineModifier,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = stringResource(id = R.string.created_at),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.width(75.dp)
)
Spacer(DoubleHorzSpacer)
NormalTimeAgo(baseNote = baseNote, Modifier.weight(1f))
MoreOptionsButton(baseNote, accountViewModel)
}
}
@Composable
@@ -897,7 +903,6 @@ fun ClickableNote(
}
}
@OptIn(ExperimentalTime::class)
@Composable
fun InnerNoteWithReactions(
baseNote: Note,
@@ -3426,7 +3431,12 @@ fun AudioHeader(noteEvent: AudioHeaderEvent, note: Note, accountViewModel: Accou
}
content?.let {
Row(verticalAlignment = CenterVertically, modifier = Modifier.fillMaxWidth().padding(top = 5.dp)) {
Row(
verticalAlignment = CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(top = 5.dp)
) {
TranslatableRichTextViewer(
content = it,
canPreview = true,

View File

@@ -114,7 +114,6 @@ import java.math.BigDecimal
import java.math.RoundingMode
import java.text.DecimalFormat
import kotlin.math.roundToInt
import kotlin.time.ExperimentalTime
import kotlin.time.measureTimedValue
@Composable
@@ -141,7 +140,6 @@ fun ReactionsRow(
Spacer(modifier = HalfDoubleVertSpacer)
}
@OptIn(ExperimentalTime::class)
@Composable
private fun InnerReactionRow(
baseNote: Note,

View File

@@ -13,18 +13,12 @@ import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.Size55dp
import com.vitorpamplona.amethyst.ui.theme.StdPadding
@Composable
fun UserCompose(
baseUser: User,
overallModifier: Modifier = remember {
Modifier
.padding(
start = 12.dp,
end = 12.dp,
top = 10.dp
)
},
overallModifier: Modifier = StdPadding,
showDiviser: Boolean = true,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
@@ -56,7 +50,6 @@ fun UserCompose(
if (showDiviser) {
Divider(
modifier = Modifier.padding(top = 10.dp),
thickness = 0.25.dp
)
}

View File

@@ -191,7 +191,7 @@ class UserReactionsViewModel(val account: Account) : ViewModel() {
takenIntoAccount.add(noteEvent.id())
}
} else if (noteEvent is LnZapEvent) {
if (noteEvent.isTaggedUser(currentUser) && noteEvent.pubKey != currentUser) {
if (noteEvent.isTaggedUser(currentUser)) { // the user might be sending his own receipts noteEvent.pubKey != currentUser
val netDate = formatDate(noteEvent.createdAt)
zaps[netDate] = (zaps[netDate] ?: BigDecimal.ZERO) + (noteEvent.amount ?: BigDecimal.ZERO)
takenIntoAccount.add(noteEvent.id())
@@ -245,7 +245,7 @@ class UserReactionsViewModel(val account: Account) : ViewModel() {
hasNewElements = true
}
} else if (noteEvent is LnZapEvent) {
if (noteEvent.isTaggedUser(currentUser) && noteEvent.pubKey != currentUser) {
if (noteEvent.isTaggedUser(currentUser)) { // && noteEvent.pubKey != currentUser User might be sending his own receipts
val netDate = formatDate(noteEvent.createdAt)
zaps[netDate] = (zaps[netDate] ?: BigDecimal.ZERO) + (noteEvent.amount ?: BigDecimal.ZERO)
takenIntoAccount.add(noteEvent.id())
@@ -326,6 +326,7 @@ class UserReactionsViewModel(val account: Account) : ViewModel() {
override fun onCleared() {
collectorJob?.cancel()
bundlerInsert.cancel()
super.onCleared()
}

View File

@@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.Divider
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -22,6 +23,7 @@ import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.ui.screen.ZapUserSetCard
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.Size25dp
import com.vitorpamplona.amethyst.ui.theme.Size55Modifier
import com.vitorpamplona.amethyst.ui.theme.newItemBackgroundColor
@@ -89,5 +91,9 @@ fun ZapUserSetCompose(zapSetCard: ZapUserSetCard, isInnerNote: Boolean = false,
UserCompose(baseUser = zapSetCard.user, accountViewModel = accountViewModel, nav = nav)
}
}
Divider(
thickness = DividerThickness
)
}
}

View File

@@ -13,11 +13,9 @@ import com.vitorpamplona.quartz.encoders.Hex
import com.vitorpamplona.quartz.encoders.Nip19
import com.vitorpamplona.quartz.encoders.bechToBytes
import com.vitorpamplona.quartz.encoders.hexToByteArray
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
@@ -89,8 +87,7 @@ class AccountStateViewModel(val context: Context) : ViewModel() {
} else {
_accountContent.update { AccountState.LoggedInViewOnly(account) }
}
val scope = CoroutineScope(Job() + Dispatchers.IO)
scope.launch {
GlobalScope.launch(Dispatchers.IO) {
ServiceManager.start(account, context)
}
GlobalScope.launch(Dispatchers.Main) {

View File

@@ -28,7 +28,6 @@ import com.vitorpamplona.quartz.events.ReactionEvent
import com.vitorpamplona.quartz.events.RepostEvent
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
@@ -76,8 +75,7 @@ open class CardFeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
private var lastNotes: Set<Note>? = null
fun refresh() {
val scope = CoroutineScope(Job() + Dispatchers.Default)
scope.launch {
viewModelScope.launch(Dispatchers.Default) {
refreshSuspended()
}
}
@@ -217,8 +215,7 @@ open class CardFeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
}
private fun updateFeed(notes: ImmutableList<Card>) {
val scope = CoroutineScope(Job() + Dispatchers.Main)
scope.launch {
viewModelScope.launch(Dispatchers.Main) {
val currentState = _feedContent.value
if (notes.isEmpty()) {
@@ -353,6 +350,8 @@ open class CardFeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
override fun onCleared() {
clear()
bundlerInsert.cancel()
bundler.cancel()
collectorJob?.cancel()
super.onCleared()
}

View File

@@ -41,7 +41,6 @@ import com.vitorpamplona.amethyst.ui.dal.VideoFeedFilter
import com.vitorpamplona.quartz.events.ChatroomKey
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
@@ -228,8 +227,7 @@ abstract class FeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel(), I
}
private fun refresh() {
val scope = CoroutineScope(Job() + Dispatchers.Default)
scope.launch {
viewModelScope.launch(Dispatchers.Default) {
refreshSuspended()
}
}
@@ -251,8 +249,7 @@ abstract class FeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel(), I
}
private fun updateFeed(notes: ImmutableList<Note>) {
val scope = CoroutineScope(Job() + Dispatchers.Main)
scope.launch {
viewModelScope.launch(Dispatchers.Main) {
val currentState = _feedContent.value
if (notes.isEmpty()) {
_feedContent.update { FeedState.Empty }
@@ -340,6 +337,8 @@ abstract class FeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel(), I
}
override fun onCleared() {
bundlerInsert.cancel()
bundler.cancel()
collectorJob?.cancel()
super.onCleared()
}

View File

@@ -14,7 +14,6 @@ import com.vitorpamplona.amethyst.ui.dal.FeedFilter
import com.vitorpamplona.amethyst.ui.dal.UserProfileZapsFeedFilter
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
@@ -36,8 +35,7 @@ open class LnZapFeedViewModel(val dataSource: FeedFilter<ZapReqResponse>) : View
val feedContent = _feedContent.asStateFlow()
private fun refresh() {
val scope = CoroutineScope(Job() + Dispatchers.Default)
scope.launch {
viewModelScope.launch(Dispatchers.Default) {
refreshSuspended()
}
}
@@ -58,8 +56,7 @@ open class LnZapFeedViewModel(val dataSource: FeedFilter<ZapReqResponse>) : View
}
private fun updateFeed(notes: ImmutableList<ZapReqResponse>) {
val scope = CoroutineScope(Job() + Dispatchers.Main)
scope.launch {
viewModelScope.launch(Dispatchers.Main) {
val currentState = _feedContent.value
if (notes.isEmpty()) {
_feedContent.update { LnZapFeedState.Empty }
@@ -96,6 +93,7 @@ open class LnZapFeedViewModel(val dataSource: FeedFilter<ZapReqResponse>) : View
}
override fun onCleared() {
bundler.cancel()
collectorJob?.cancel()
super.onCleared()
}

View File

@@ -91,6 +91,11 @@ class RelayFeedViewModel : ViewModel() {
refreshSuspended()
}
}
override fun onCleared() {
bundler.cancel()
super.onCleared()
}
}
@OptIn(ExperimentalMaterialApi::class)

View File

@@ -18,7 +18,6 @@ import com.vitorpamplona.amethyst.ui.dal.UserProfileFollowersFeedFilter
import com.vitorpamplona.amethyst.ui.dal.UserProfileFollowsFeedFilter
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
@@ -64,8 +63,7 @@ open class UserFeedViewModel(val dataSource: FeedFilter<User>) : ViewModel(), In
val feedContent = _feedContent.asStateFlow()
private fun refresh() {
val scope = CoroutineScope(Job() + Dispatchers.Default)
scope.launch {
viewModelScope.launch(Dispatchers.Default) {
refreshSuspended()
}
}
@@ -87,8 +85,7 @@ open class UserFeedViewModel(val dataSource: FeedFilter<User>) : ViewModel(), In
}
private fun updateFeed(notes: ImmutableList<User>) {
val scope = CoroutineScope(Job() + Dispatchers.Main)
scope.launch {
viewModelScope.launch(Dispatchers.Main) {
val currentState = _feedContent.value
if (notes.isEmpty()) {
_feedContent.update { UserFeedState.Empty }
@@ -125,6 +122,7 @@ open class UserFeedViewModel(val dataSource: FeedFilter<User>) : ViewModel(), In
}
override fun onCleared() {
bundler.cancel()
collectorJob?.cancel()
super.onCleared()
}

View File

@@ -105,12 +105,12 @@ import com.vitorpamplona.amethyst.ui.note.LoadChannel
import com.vitorpamplona.amethyst.ui.note.MoreOptionsButton
import com.vitorpamplona.amethyst.ui.note.NoteAuthorPicture
import com.vitorpamplona.amethyst.ui.note.NoteUsernameDisplay
import com.vitorpamplona.amethyst.ui.note.TimeAgo
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.note.ZapReaction
import com.vitorpamplona.amethyst.ui.note.routeFor
import com.vitorpamplona.amethyst.ui.note.timeAgo
import com.vitorpamplona.amethyst.ui.note.timeAgoShort
import com.vitorpamplona.amethyst.ui.screen.NostrChannelFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.RefreshingChatroomFeedView
import com.vitorpamplona.amethyst.ui.screen.equalImmutableLists
@@ -580,7 +580,7 @@ fun ChannelHeader(
)
if (expanded.value) {
LongChannelHeader(baseChannel, accountViewModel, nav)
LongChannelHeader(baseChannel = baseChannel, accountViewModel = accountViewModel, nav = nav)
}
}
@@ -722,6 +722,7 @@ fun ShortChannelHeader(
@Composable
fun LongChannelHeader(
baseChannel: Channel,
lineModifier: Modifier = Modifier.padding(horizontal = 10.dp, vertical = 5.dp),
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
@@ -731,18 +732,14 @@ fun LongChannelHeader(
} ?: return
Row(
Modifier
.fillMaxWidth()
.padding(top = 10.dp)
lineModifier
) {
val summary = remember(channelState) {
channel.summary()?.ifBlank { null }
}
Column(
Modifier
.weight(1f)
.padding(start = 10.dp)
Modifier.weight(1f)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
val defaultBackground = MaterialTheme.colors.background
@@ -769,7 +766,9 @@ fun LongChannelHeader(
}
if (baseChannel is LiveActivitiesChannel) {
val hashtags = remember(baseChannel.info) { baseChannel.info?.hashtags()?.toImmutableList() ?: persistentListOf() }
val hashtags = remember(baseChannel.info) {
baseChannel.info?.hashtags()?.toImmutableList() ?: persistentListOf()
}
DisplayUncitedHashtags(hashtags, summary ?: "", nav)
}
}
@@ -784,28 +783,37 @@ fun LongChannelHeader(
}
}
Spacer(DoubleVertSpacer)
Row(
Modifier
.fillMaxWidth()
.padding(start = 10.dp),
verticalAlignment = Alignment.CenterVertically
) {
LoadNote(baseNoteHex = channel.idHex) {
it?.let {
LoadNote(baseNoteHex = channel.idHex) { loadingNote ->
loadingNote?.let { note ->
Row(
lineModifier,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = stringResource(id = R.string.owner),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.width(55.dp)
modifier = Modifier.width(75.dp)
)
Spacer(DoubleHorzSpacer)
NoteAuthorPicture(it, nav, accountViewModel, Size25dp)
NoteAuthorPicture(note, nav, accountViewModel, Size25dp)
Spacer(DoubleHorzSpacer)
NoteUsernameDisplay(it, remember { Modifier.weight(1f) })
TimeAgo(it)
MoreOptionsButton(it, accountViewModel)
NoteUsernameDisplay(note, remember { Modifier.weight(1f) })
}
Row(
lineModifier,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = stringResource(id = R.string.created_at),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.width(75.dp)
)
Spacer(DoubleHorzSpacer)
NormalTimeAgo(note, remember { Modifier.weight(1f) })
MoreOptionsButton(note, accountViewModel)
}
}
}
@@ -831,9 +839,7 @@ fun LongChannelHeader(
participantUsers.forEach {
Row(
Modifier
.fillMaxWidth()
.padding(start = 10.dp, top = 10.dp)
lineModifier
.clickable {
nav("User/${it.second.pubkeyHex}")
},
@@ -856,6 +862,24 @@ fun LongChannelHeader(
}
}
@Composable
fun NormalTimeAgo(baseNote: Note, modifier: Modifier) {
val nowStr = stringResource(id = R.string.now)
val time by remember(baseNote) {
derivedStateOf {
timeAgoShort(baseNote.createdAt() ?: 0, nowStr)
}
}
Text(
text = time,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = modifier
)
}
@Composable
private fun ShortChannelActionOptions(
channel: PublicChatChannel,

View File

@@ -5,7 +5,6 @@ import androidx.compose.animation.Crossfade
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
@@ -550,7 +549,7 @@ fun GroupChatroomHeader(
}
if (expanded.value) {
LongRoomHeader(room, accountViewModel, nav)
LongRoomHeader(room = room, accountViewModel = accountViewModel, nav = nav)
}
}
@@ -687,15 +686,18 @@ fun NewSubjectView(onClose: () -> Unit, accountViewModel: AccountViewModel, room
}
@Composable
fun LongRoomHeader(room: ChatroomKey, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
fun LongRoomHeader(
room: ChatroomKey,
lineModifier: Modifier = StdPadding,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val list = remember(room) {
room.users.toPersistentList()
}
Row(
modifier = Modifier
.padding(top = 10.dp)
.fillMaxWidth(),
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
@@ -712,16 +714,18 @@ fun LongRoomHeader(room: ChatroomKey, accountViewModel: AccountViewModel, nav: (
}
LazyColumn(
modifier = Modifier.fillMaxHeight(),
contentPadding = PaddingValues(
bottom = 10.dp
),
modifier = Modifier,
state = rememberLazyListState()
) {
itemsIndexed(list, key = { _, item -> item }) { _, item ->
LoadUser(baseUserHex = item) {
if (it != null) {
UserCompose(baseUser = it, accountViewModel = accountViewModel, nav = nav)
UserCompose(
baseUser = it,
overallModifier = lineModifier,
accountViewModel = accountViewModel,
nav = nav
)
}
}
}

View File

@@ -25,6 +25,8 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.navigation.Route
import com.vitorpamplona.quartz.events.ChannelCreateEvent
import com.vitorpamplona.quartz.events.ChatroomKeyable
import com.vitorpamplona.quartz.events.GiftWrapEvent
import com.vitorpamplona.quartz.events.SealedGossipEvent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -75,9 +77,17 @@ fun LoadRedirectScreen(baseNote: Note, accountViewModel: AccountViewModel, nav:
LaunchedEffect(key1 = noteState) {
scope.launch {
val note = noteState?.note
val event = note?.event
val channelHex = note?.channelHex()
val note = noteState?.note ?: return@launch
var event = note.event
val channelHex = note.channelHex()
if (event is GiftWrapEvent) {
event = accountViewModel.unwrap(event)
}
if (event is SealedGossipEvent) {
event = accountViewModel.unseal(event)
}
if (event == null) {
// stay here, loading
@@ -85,7 +95,7 @@ fun LoadRedirectScreen(baseNote: Note, accountViewModel: AccountViewModel, nav:
nav("Channel/${note.idHex}")
} else if (event is ChatroomKeyable) {
note.author?.let {
val withKey = (note.event as ChatroomKeyable)
val withKey = (event as ChatroomKeyable)
.chatroomKey(accountViewModel.userProfile().pubkeyHex)
withContext(Dispatchers.IO) {

View File

@@ -217,7 +217,7 @@ fun MainScreen(
}
},
floatingActionButton = {
FloatingButtons(navState, accountViewModel, accountStateViewModel, nav)
FloatingButtons(navState, accountViewModel, accountStateViewModel, nav, navBottomRow)
},
scaffoldState = scaffoldState
) {
@@ -247,7 +247,8 @@ fun FloatingButtons(
navEntryState: State<NavBackStackEntry?>,
accountViewModel: AccountViewModel,
accountStateViewModel: AccountStateViewModel,
nav: (String) -> Unit
nav: (String) -> Unit,
navScrollToTop: (Route, Boolean) -> Unit
) {
val accountState by accountStateViewModel.accountContent.collectAsState()
@@ -260,7 +261,7 @@ fun FloatingButtons(
// Does nothing.
}
is AccountState.LoggedIn -> {
WritePermissionButtons(navEntryState, accountViewModel, nav)
WritePermissionButtons(navEntryState, accountViewModel, nav, navScrollToTop)
}
}
}
@@ -270,7 +271,8 @@ fun FloatingButtons(
private fun WritePermissionButtons(
navEntryState: State<NavBackStackEntry?>,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
nav: (String) -> Unit,
navScrollToTop: (Route, Boolean) -> Unit
) {
val currentRoute by remember(navEntryState.value) {
derivedStateOf {
@@ -281,7 +283,7 @@ private fun WritePermissionButtons(
when (currentRoute) {
Route.Home.base -> NewNoteButton(accountViewModel, nav)
Route.Message.base -> ChannelFabColumn(accountViewModel, nav)
Route.Video.base -> NewImageButton(accountViewModel, nav)
Route.Video.base -> NewImageButton(accountViewModel, nav, navScrollToTop)
Route.Community.base -> {
val communityId by remember(navEntryState.value) {
derivedStateOf {

View File

@@ -685,9 +685,6 @@ private fun ProfileHeader(
) {
MessageButton(baseUser, accountViewModel, nav)
// No need for this button anymore
// NPubCopyButton(baseUser)
ProfileActions(baseUser, accountViewModel)
}
}

View File

@@ -195,6 +195,11 @@ class SearchBarViewModel(val account: Account) : ViewModel() {
}
}
override fun onCleared() {
bundler.cancel()
super.onCleared()
}
fun isSearchingFun() = searchValue.isNotBlank()
class Factory(val account: Account) : ViewModelProvider.Factory {

View File

@@ -103,7 +103,7 @@
<string name="uploading">Uploading…</string>
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">User does not have a lightning address setup to receive sats</string>
<string name="reply_here">"reply here.. "</string>
<string name="copies_the_note_id_to_the_clipboard_for_sharing">Copies the Note ID to the clipboard for sharing</string>
<string name="copies_the_note_id_to_the_clipboard_for_sharing">Copies the Note ID to the clipboard for sharing in Nostr</string>
<string name="copy_channel_id_note_to_the_clipboard">Copy Channel ID (Note) to the Clipboard</string>
<string name="edits_the_channel_metadata">Edits the Channel Metadata</string>
<string name="join">Join</string>
@@ -544,4 +544,9 @@
<string name="automatically_show_url_preview_description">Show URL previews</string>
<string name="load_image_description">When to load images</string>
<string name="copy_url_to_clipboard">Copy URL to clipboard</string>
<string name="copy_the_note_id_to_the_clipboard">Copy Note ID to clipboard</string>
<string name="created_at">Created at</string>
<string name="rules">Rules</string>
</resources>

View File

@@ -8,22 +8,37 @@ import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.getOrCreateDMChannel
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.getOrCreateZapChannel
import com.vitorpamplona.quartz.events.Event
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
class PushNotificationReceiverService : FirebaseMessagingService() {
val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
// this is called when a message is received
override fun onMessageReceived(remoteMessage: RemoteMessage) {
remoteMessage.data.let {
val eventStr = remoteMessage.data["event"] ?: return
val event = Event.fromJson(eventStr)
EventNotificationConsumer(applicationContext).consume(event)
scope.launch(Dispatchers.IO) {
remoteMessage.data.let {
val eventStr = remoteMessage.data["event"] ?: return@let
val event = Event.fromJson(eventStr)
EventNotificationConsumer(applicationContext).consume(event)
}
}
}
override fun onDestroy() {
scope.cancel()
super.onDestroy()
}
override fun onNewToken(token: String) {
RegisterAccounts(LocalPreferences.allSavedAccounts()).go(token)
notificationManager().getOrCreateZapChannel(applicationContext)
notificationManager().getOrCreateDMChannel(applicationContext)
scope.launch(Dispatchers.IO) {
RegisterAccounts(LocalPreferences.allSavedAccounts()).go(token)
notificationManager().getOrCreateZapChannel(applicationContext)
notificationManager().getOrCreateDMChannel(applicationContext)
}
}
fun notificationManager(): NotificationManager {

View File

@@ -1,31 +1,18 @@
package com.vitorpamplona.amethyst.service.notifications
import android.util.Log
import com.google.android.gms.tasks.OnCompleteListener
import com.google.firebase.messaging.FirebaseMessaging
import com.vitorpamplona.amethyst.AccountInfo
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.tasks.await
class PushNotificationUtils {
fun init(accounts: List<AccountInfo>) {
val scope = CoroutineScope(Job() + Dispatchers.IO)
scope.launch {
// get user notification token provided by firebase
FirebaseMessaging.getInstance().token.addOnCompleteListener(
OnCompleteListener { task ->
if (!task.isSuccessful) {
Log.w("FirebaseMsgService", "Fetching FCM registration token failed", task.exception)
return@OnCompleteListener
}
// Get new FCM registration token
val notificationToken = task.result
RegisterAccounts(accounts).go(notificationToken)
}
)
suspend fun init(accounts: List<AccountInfo>) = with(Dispatchers.IO) {
// get user notification token provided by firebase
try {
RegisterAccounts(accounts).go(FirebaseMessaging.getInstance().token.await())
} catch (e: Exception) {
Log.e("Firebase token", "failed to get firebase token", e)
}
}
}

View File

@@ -2,11 +2,13 @@ package com.vitorpamplona.amethyst.service
import android.os.Looper
import io.mockk.MockKAnnotations
import io.mockk.coEvery
import io.mockk.every
import io.mockk.impl.annotations.SpyK
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkAll
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
@@ -30,7 +32,7 @@ class Nip05VerifierTest {
}
@Test
fun `test with matching case on user name`() {
fun `test with matching case on user name`() = runBlocking {
// Set-up
val userNameToTest = ALL_UPPER_CASE_USER_NAME
val expectedPubKey = "ca29c211f1c72d5b6622268ff43d2288ea2b2cb5b9aa196ff9f1704fc914b71b"
@@ -41,7 +43,7 @@ class Nip05VerifierTest {
" }\n" +
"}"
every { nip05Verifier.fetchNip05Json(any(), any(), any()) } answers {
coEvery { nip05Verifier.fetchNip05Json(any(), any(), any()) } answers {
secondArg<(String) -> Unit>().invoke(nostrJson)
}
@@ -64,7 +66,7 @@ class Nip05VerifierTest {
}
@Test
fun `test with NOT matching case on user name`() {
fun `test with NOT matching case on user name`() = runBlocking {
// Set-up
val expectedPubKey = "ca29c211f1c72d5b6622268ff43d2288ea2b2cb5b9aa196ff9f1704fc914b71b"
@@ -73,7 +75,7 @@ class Nip05VerifierTest {
" \"$ALL_UPPER_CASE_USER_NAME\": \"$expectedPubKey\" \n" +
" }\n" +
"}"
every { nip05Verifier.fetchNip05Json(any(), any(), any()) } answers {
coEvery { nip05Verifier.fetchNip05Json(any(), any(), any()) } answers {
secondArg<(String) -> Unit>().invoke(nostrJson)
}

View File

@@ -11,6 +11,7 @@ buildscript {
coil_version = '2.4.0'
vico_version = '1.7.3'
exoplayer_version = '1.1.0'
media3_version = '1.1.0'
}
dependencies {
classpath 'com.google.gms:google-services:4.3.15'

View File

@@ -17,7 +17,7 @@ class ChatMessageEvent(
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig), ChatroomKeyable {
) : WrappedEvent(id, pubKey, createdAt, kind, tags, content, sig), ChatroomKeyable {
/**
* Recepients intended to receive this conversation
*/

View File

@@ -173,7 +173,7 @@ class ContactListEvent(
return create(
content = earlierVersion.content,
tags = earlierVersion.tags.filter { it.size > 1 && it[1] != hashtag },
tags = earlierVersion.tags.filter { it.size > 1 && !it[1].equals(hashtag, true) },
privateKey = privateKey,
createdAt = createdAt
)

View File

@@ -362,6 +362,22 @@ open class Event(
}
}
@Immutable
open class WrappedEvent(
id: HexKey,
@JsonProperty("pubkey")
pubKey: HexKey,
@JsonProperty("created_at")
createdAt: Long,
kind: Int,
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
@Transient
var host: Event? = null // host event to broadcast when needed
}
@Immutable
interface AddressableEvent {
fun dTag(): String

View File

@@ -28,6 +28,10 @@ class GiftWrapEvent(
if (cachedInnerEvent.contains(hex)) return cachedInnerEvent[hex]
val myInnerEvent = unwrap(privKey = privKey)
if (myInnerEvent is WrappedEvent) {
myInnerEvent.host = this
}
cachedInnerEvent = cachedInnerEvent + Pair(hex, myInnerEvent)
return myInnerEvent
}

View File

@@ -40,6 +40,10 @@ class LiveActivitiesEvent(
}
}
fun participantsIntersect(keySet: Set<String>): Boolean {
return tags.any { it.size > 1 && it[0] == "p" && it[1] in keySet }
}
companion object {
const val kind = 30311

View File

@@ -20,7 +20,7 @@ class SealedGossipEvent(
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
): WrappedEvent(id, pubKey, createdAt, kind, tags, content, sig) {
@Transient
private var cachedInnerEvent: Map<HexKey, Event?> = mapOf()
@@ -30,6 +30,10 @@ class SealedGossipEvent(
val gossip = unseal(privKey = privKey)
val event = gossip?.mergeWith(this)
if (event is WrappedEvent) {
event.host = host ?: this
}
cachedInnerEvent = cachedInnerEvent + Pair(hex, event)
return event
}