diff --git a/app/build.gradle b/app/build.gradle index 2da3a6079..b229f89ee 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -12,8 +12,8 @@ android { applicationId "com.vitorpamplona.amethyst" minSdk 26 targetSdk 33 - versionCode 108 - versionName "0.30.1" + versionCode 109 + versionName "0.30.2" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { @@ -114,7 +114,7 @@ dependencies { implementation "androidx.biometric:biometric-ktx:1.2.0-alpha05" // Bitcoin secp256k1 bindings to Android - implementation 'fr.acinq.secp256k1:secp256k1-kmp-jni-android:0.7.1' + implementation 'fr.acinq.secp256k1:secp256k1-kmp-jni-android:0.8.0' // Nostr Base Protocol implementation('com.github.vitorpamplona.NostrPostr:nostrpostrlib:master-SNAPSHOT') { @@ -137,14 +137,14 @@ dependencies { implementation 'androidx.security:security-crypto-ktx:1.1.0-alpha05' // view videos - implementation 'com.google.android.exoplayer:exoplayer:2.18.4' + implementation 'com.google.android.exoplayer:exoplayer:2.18.5' // Load images from the web. - implementation "io.coil-kt:coil-compose:2.2.2" + implementation "io.coil-kt:coil-compose:$coil_version" // view gifs - implementation "io.coil-kt:coil-gif:2.2.2" + implementation "io.coil-kt:coil-gif:$coil_version" // view svgs - implementation("io.coil-kt:coil-svg:2.2.2") + implementation "io.coil-kt:coil-svg:$coil_version" // Rendering clickable text implementation "com.google.accompanist:accompanist-flowlayout:$accompanist_version" diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index c22a472ac..518e6a539 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -43,6 +43,13 @@ + + + + + + + diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index dea1ea08d..ce1c4d922 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -156,7 +156,7 @@ class Account( } } - fun createZapRequestFor(note: Note, pollOption: Int?): LnZapRequestEvent? { + fun createZapRequestFor(note: Note, pollOption: Int?, message: String = ""): LnZapRequestEvent? { if (!isWriteable()) return null note.event?.let { event -> @@ -167,7 +167,10 @@ class Account( loggedIn.privKey!!, pollOption ) - } + /*note.event?.let { + return LnZapRequestEvent.create(it, userProfile().latestContactList?.relays()?.keys?.ifEmpty { null } ?: localRelays.map { it.url }.toSet(), loggedIn.privKey!!, message) + }*/ + return null } @@ -189,10 +192,10 @@ class Account( return createZapRequestFor(user.pubkeyHex) } - fun createZapRequestFor(userPubKeyHex: String): LnZapRequestEvent? { + fun createZapRequestFor(userPubKeyHex: String, message: String = ""): LnZapRequestEvent? { if (!isWriteable()) return null - return LnZapRequestEvent.create(userPubKeyHex, userProfile().latestContactList?.relays()?.keys?.ifEmpty { null } ?: localRelays.map { it.url }.toSet(), loggedIn.privKey!!) + return LnZapRequestEvent.create(userPubKeyHex, userProfile().latestContactList?.relays()?.keys?.ifEmpty { null } ?: localRelays.map { it.url }.toSet(), loggedIn.privKey!!, message) } fun report(note: Note, type: ReportEvent.ReportType, content: String = "") { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/Nip47.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/Nip47.kt index 8b38638b3..4fb1bdcde 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/Nip47.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/Nip47.kt @@ -1,10 +1,11 @@ package com.vitorpamplona.amethyst.ui.note import android.net.Uri +import com.vitorpamplona.amethyst.model.HexKey import com.vitorpamplona.amethyst.model.decodePublicKey import com.vitorpamplona.amethyst.model.toHexKey -data class Nip47URI(val pubKeyHex: String, val relayUri: String?, val secret: String?) +data class Nip47URI(val pubKeyHex: HexKey, val relayUri: String?, val secret: HexKey?) // Rename to the corect nip number when ready. object Nip47 { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSearchEventOrUserDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSearchEventOrUserDataSource.kt index b59120a58..fda096ec5 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSearchEventOrUserDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSearchEventOrUserDataSource.kt @@ -30,25 +30,25 @@ object NostrSearchEventOrUserDataSource : NostrDataSource("SingleEventFeed") { null } - if (hexToWatch == null) { - return null - } - // downloads all the reactions to a given event. - return listOf( - TypedFilter( - types = FeedType.values().toSet(), - filter = JsonFilter( - ids = listOfNotNull(hexToWatch) + return listOfNotNull( + hexToWatch?.let { + TypedFilter( + types = FeedType.values().toSet(), + filter = JsonFilter( + ids = listOfNotNull(hexToWatch) + ) ) - ), - TypedFilter( - types = FeedType.values().toSet(), - filter = JsonFilter( - kinds = listOf(MetadataEvent.kind), - authors = listOfNotNull(hexToWatch) + }, + hexToWatch?.let { + TypedFilter( + types = FeedType.values().toSet(), + filter = JsonFilter( + kinds = listOf(MetadataEvent.kind), + authors = listOfNotNull(hexToWatch) + ) ) - ), + }, TypedFilter( types = FeedType.values().toSet(), filter = JsonFilter( diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt index 272a93b39..ca34b801b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt @@ -71,8 +71,6 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") { return null } - val now = Date().time / 1000 - return reactionsToWatch.map { TypedFilter( types = FeedType.values().toSet(), @@ -142,7 +140,7 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") { val addresses = createAddressFilter() val addressReactions = createTagToAddressFilter() - singleEventChannel.typedFilters = listOfNotNull(reactions, missing, addresses, addressReactions).flatten().ifEmpty { null } + singleEventChannel.typedFilters = listOfNotNull(missing, addresses, reactions, addressReactions).flatten().ifEmpty { null } } fun add(eventId: Note) { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapRequestEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapRequestEvent.kt index 360793456..fae2d053b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapRequestEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapRequestEvent.kt @@ -24,9 +24,10 @@ class LnZapRequestEvent( relays: Set, privateKey: ByteArray, pollOption: Int?, + message: String, createdAt: Long = Date().time / 1000 ): LnZapRequestEvent { - val content = "" + val content = message val pubKey = Utils.pubkeyCreate(privateKey).toHexKey() var tags = listOf( listOf("e", originalNote.id()), @@ -49,14 +50,19 @@ class LnZapRequestEvent( userHex: String, relays: Set, privateKey: ByteArray, + pollOption: Int?, + message: String, createdAt: Long = Date().time / 1000 ): LnZapRequestEvent { - val content = "" + val content = message val pubKey = Utils.pubkeyCreate(privateKey).toHexKey() - val tags = listOf( + var tags = listOf( listOf("p", userHex), listOf("relays") + relays ) + if (pollOption != null && pollOption >= 0) { + tags = tags + listOf(listOf(POLL_OPTION, pollOption.toString())) + } val id = generateId(pubKey, createdAt, kind, tags, content) val sig = Utils.sign(id, privateKey) return LnZapRequestEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey()) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Constants.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Constants.kt index 5632e9da5..a167a2d99 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Constants.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Constants.kt @@ -4,6 +4,7 @@ import com.vitorpamplona.amethyst.model.RelaySetupInfo object Constants { val activeTypes = setOf(FeedType.FOLLOWS, FeedType.PRIVATE_DMS) + val activeTypesChats = setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.PRIVATE_DMS) val activeTypesGlobalChats = setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.PRIVATE_DMS, FeedType.GLOBAL) val activeTypesSearch = setOf(FeedType.SEARCH) @@ -14,16 +15,18 @@ object Constants { } val defaultRelays = arrayOf( - // Free relays - RelaySetupInfo("wss://nostr.bitcoiner.social", read = true, write = true, feedTypes = activeTypes), - RelaySetupInfo("wss://relay.nostr.bg", read = true, write = true, feedTypes = activeTypes), + // Free relays for DMs and Follows + RelaySetupInfo("wss://no.str.cr", read = true, write = true, feedTypes = activeTypes), RelaySetupInfo("wss://relay.snort.social", read = true, write = true, feedTypes = activeTypes), RelaySetupInfo("wss://relay.damus.io", read = true, write = true, feedTypes = activeTypes), - RelaySetupInfo("wss://nostr.oxtr.dev", read = true, write = true, feedTypes = activeTypes), - RelaySetupInfo("wss://nostr-pub.wellorder.net", read = true, write = true, feedTypes = activeTypes), - RelaySetupInfo("wss://nostr.mom", read = true, write = true, feedTypes = activeTypes), - RelaySetupInfo("wss://no.str.cr", read = true, write = true, feedTypes = activeTypes), - RelaySetupInfo("wss://nos.lol", read = true, write = true, feedTypes = activeTypes), + + // Chats + RelaySetupInfo("wss://nostr.bitcoiner.social", read = true, write = true, feedTypes = activeTypesChats), + RelaySetupInfo("wss://relay.nostr.bg", read = true, write = true, feedTypes = activeTypesChats), + RelaySetupInfo("wss://nostr.oxtr.dev", read = true, write = true, feedTypes = activeTypesChats), + RelaySetupInfo("wss://nostr-pub.wellorder.net", read = true, write = true, feedTypes = activeTypesChats), + RelaySetupInfo("wss://nostr.mom", read = true, write = true, feedTypes = activeTypesChats), + RelaySetupInfo("wss://nos.lol", read = true, write = true, feedTypes = activeTypesChats), // Less Reliable // NewRelayListViewModel.Relay("wss://nostr.orangepill.dev", read = true, write = true, feedTypes = activeTypes), diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Relay.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Relay.kt index c4f819831..8ab0b4d97 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Relay.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Relay.kt @@ -182,7 +182,7 @@ class Relay( val filters = Client.getSubscriptionFilters(requestId).filter { activeTypes.intersect(it.types).isNotEmpty() } if (filters.isNotEmpty()) { val request = - """["REQ","$requestId",${filters.take(10).joinToString(",") { it.filter.toJson(url) }}]""" + """["REQ","$requestId",${filters.take(40).joinToString(",") { it.filter.toJson(url) }}]""" // println("FILTERSSENT $url $request") socket?.send(request) eventUploadCounterInBytes += request.bytesUsedInMemory() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt index 3afecdcb7..49823b3e4 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt @@ -21,6 +21,8 @@ import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.ServiceManager import com.vitorpamplona.amethyst.service.nip19.Nip19 import com.vitorpamplona.amethyst.service.relays.Client +import com.vitorpamplona.amethyst.ui.navigation.Route +import com.vitorpamplona.amethyst.ui.note.Nip47 import com.vitorpamplona.amethyst.ui.screen.AccountScreen import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel import com.vitorpamplona.amethyst.ui.theme.AmethystTheme @@ -28,6 +30,8 @@ import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch +import java.net.URLEncoder +import java.nio.charset.StandardCharsets class MainActivity : FragmentActivity() { override fun onCreate(savedInstanceState: Bundle?) { @@ -40,6 +44,14 @@ class MainActivity : FragmentActivity() { Nip19.Type.EVENT -> "Event/${nip19.hex}" Nip19.Type.ADDRESS -> "Note/${nip19.hex}" else -> null + } ?: try { + intent?.data?.toString()?.let { + Nip47.parse(it) + val encodedUri = URLEncoder.encode(it, StandardCharsets.UTF_8.toString()) + Route.Home.base + "?nip47=" + encodedUri + } + } catch (e: Exception) { + null } Coil.setImageLoader { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt index 0b83d9faa..25635f39b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt @@ -49,6 +49,9 @@ open class NewPostViewModel : ViewModel() { this.mentions = currentMentions.plus(replyUser) } } + } ?: { + replyTos = null + mentions = null } quote?.let { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataView.kt index 3a683ec89..130c20f83 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataView.kt @@ -1,5 +1,6 @@ package com.vitorpamplona.amethyst.ui.actions +import android.widget.Toast import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -18,6 +19,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.unit.dp @@ -30,9 +32,14 @@ import com.vitorpamplona.amethyst.model.Account @Composable fun NewUserMetadataView(onClose: () -> Unit, account: Account) { val postViewModel: NewUserMetadataViewModel = viewModel() + val context = LocalContext.current LaunchedEffect(Unit) { postViewModel.load(account) + + postViewModel.imageUploadingError.collect { error -> + Toast.makeText(context, error, Toast.LENGTH_SHORT).show() + } } Dialog( @@ -141,6 +148,15 @@ fun NewUserMetadataView(onClose: () -> Unit, account: Account) { color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) ) }, + leadingIcon = { + UploadFromGallery( + isUploading = postViewModel.isUploadingImageForPicture, + tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), + modifier = Modifier.padding(start = 5.dp) + ) { + postViewModel.uploadForPicture(it, context) + } + }, singleLine = true ) @@ -157,6 +173,15 @@ fun NewUserMetadataView(onClose: () -> Unit, account: Account) { color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) ) }, + leadingIcon = { + UploadFromGallery( + isUploading = postViewModel.isUploadingImageForBanner, + tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), + modifier = Modifier.padding(start = 5.dp) + ) { + postViewModel.uploadForBanner(it, context) + } + }, singleLine = true ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt index 0b8e272ff..e6a9bf2e0 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt @@ -1,13 +1,20 @@ package com.vitorpamplona.amethyst.ui.actions +import android.content.Context +import android.net.Uri +import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.node.ObjectNode import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.service.model.GitHubIdentity import com.vitorpamplona.amethyst.service.model.MastodonIdentity import com.vitorpamplona.amethyst.service.model.TwitterIdentity +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.launch import java.io.ByteArrayInputStream import java.io.StringWriter @@ -30,6 +37,10 @@ class NewUserMetadataViewModel : ViewModel() { val github = mutableStateOf("") val mastodon = mutableStateOf("") + var isUploadingImageForPicture by mutableStateOf(false) + var isUploadingImageForBanner by mutableStateOf(false) + val imageUploadingError = MutableSharedFlow() + fun load(account: Account) { this.account = account @@ -127,4 +138,49 @@ class NewUserMetadataViewModel : ViewModel() { github.value = "" mastodon.value = "" } + + fun uploadForPicture(uri: Uri, context: Context) { + upload( + uri, + context, + onUploading = { + isUploadingImageForPicture = it + }, + onUploaded = { + picture.value = it + } + ) + } + + fun uploadForBanner(uri: Uri, context: Context) { + upload( + uri, + context, + onUploading = { + isUploadingImageForBanner = it + }, + onUploaded = { + banner.value = it + } + ) + } + + fun upload(it: Uri, context: Context, onUploading: (Boolean) -> Unit, onUploaded: (String) -> Unit) { + onUploading(true) + + ImageUploader.uploadImage( + uri = it, + contentResolver = context.contentResolver, + onSuccess = { imageUrl -> + onUploading(false) + onUploaded(imageUrl) + }, + onError = { + onUploading(false) + viewModelScope.launch { + imageUploadingError.emit("Failed to upload the image / video") + } + } + ) + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/InvoiceRequest.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/InvoiceRequest.kt index 8cf0b80c4..2fff33f83 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/InvoiceRequest.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/InvoiceRequest.kt @@ -130,7 +130,7 @@ fun InvoiceRequest(lud16: String, toUserPubKeyHex: String, account: Account, onC Button( modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp), onClick = { - val zapRequest = account.createZapRequestFor(toUserPubKeyHex) + val zapRequest = account.createZapRequestFor(toUserPubKeyHex, message) LightningAddressResolver().lnAddressInvoice( lud16, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableImageView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableImageView.kt index 84486200d..729ecadfc 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableImageView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableImageView.kt @@ -10,8 +10,12 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.InlineTextContent +import androidx.compose.foundation.text.appendInlineContent +import androidx.compose.material.LocalTextStyle import androidx.compose.material.MaterialTheme import androidx.compose.material.Surface +import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -24,14 +28,21 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.Placeholder +import androidx.compose.ui.text.PlaceholderVerticalAlign +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import coil.compose.AsyncImage +import coil.compose.AsyncImagePainter import com.google.accompanist.pager.ExperimentalPagerApi import com.google.accompanist.pager.PagerState import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.actions.CloseButton +import com.vitorpamplona.amethyst.ui.actions.LoadingAnimation import com.vitorpamplona.amethyst.ui.actions.SaveToGallery import net.engawapg.lib.zoomable.rememberZoomState import net.engawapg.lib.zoomable.zoomable @@ -46,6 +57,11 @@ fun ZoomableImageView(word: String, images: List = listOf(word)) { mutableStateOf(false) } + // store the dialog open or close state + var imageState by remember { + mutableStateOf(null) + } + if (imageExtension.matcher(word).matches()) { AsyncImage( model = word, @@ -63,8 +79,48 @@ fun ZoomableImageView(word: String, images: List = listOf(word)) { .combinedClickable( onClick = { dialogOpen = true }, onLongClick = { clipboardManager.setText(AnnotatedString(word)) } - ) + ), + onLoading = { + imageState = it + }, + onSuccess = { + imageState = it + } ) + + if (imageState !is AsyncImagePainter.State.Success) { + ClickableUrl(urlText = "$word ", url = word) + + val myId = "inlineContent" + val emptytext = buildAnnotatedString { + withStyle( + LocalTextStyle.current.copy(color = MaterialTheme.colors.primary).toSpanStyle() + ) { + append("") + appendInlineContent(myId, "[icon]") + } + } + val inlineContent = mapOf( + Pair( + myId, + InlineTextContent( + Placeholder( + width = 17.sp, + height = 17.sp, + placeholderVerticalAlign = PlaceholderVerticalAlign.Center + ) + ) { + LoadingAnimation() + } + ) + ) + + // Empty Text for Size of Icon + Text( + text = emptytext, + inlineContent = inlineContent + ) + } } else { VideoView(word) { dialogOpen = true } } @@ -86,7 +142,9 @@ fun ZoomableImageDialog(imageUrl: String, allImages: List = listOf(image var pagerState: PagerState = remember { PagerState() } Row( - modifier = Modifier.padding(10.dp).fillMaxWidth(), + modifier = Modifier + .padding(10.dp) + .fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/NotificationFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/NotificationFeedFilter.kt index 2b01244b0..507e5ccab 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/NotificationFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/NotificationFeedFilter.kt @@ -45,4 +45,8 @@ object NotificationFeedFilter : FeedFilter() { .toList() .reversed() } + + fun isDifferentAccount(account: Account): Boolean { + return this::account.isInitialized && this.account != account + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index c536fb83c..d7418272c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -1,8 +1,12 @@ package com.vitorpamplona.amethyst.ui.navigation import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost @@ -39,6 +43,7 @@ fun AppNavigation( nextPage: String? = null ) { val homePagerState = rememberPagerState() + var actionableNextPage by remember { mutableStateOf(nextPage) } // Avoids creating ViewModels for performance reasons (up to 1 second delays) val accountState by accountViewModel.accountLiveData.observeAsState() @@ -76,8 +81,9 @@ fun AppNavigation( } Route.Home.let { route -> - composable(route.route, route.arguments, content = { + composable(route.route, route.arguments, content = { it -> val scrollToTop = it.arguments?.getBoolean("scrollToTop") ?: false + val nip47 = it.arguments?.getString("nip47") HomeScreen( homeFeedViewModel = homeFeedViewModel, @@ -85,6 +91,28 @@ fun AppNavigation( accountViewModel = accountViewModel, navController = navController, pagerState = homePagerState, + scrollToTop = scrollToTop, + nip47 = nip47 + ) + + // Avoids running scroll to top when back button is pressed + if (scrollToTop) { + it.arguments?.remove("scrollToTop") + } + if (nip47 != null) { + it.arguments?.remove("nip47") + } + }) + } + + Route.Notification.let { route -> + composable(route.route, route.arguments, content = { + val scrollToTop = it.arguments?.getBoolean("scrollToTop") ?: false + + NotificationScreen( + notifFeedViewModel = notifFeedViewModel, + accountViewModel = accountViewModel, + navController = navController, scrollToTop = scrollToTop ) @@ -96,7 +124,6 @@ fun AppNavigation( } composable(Route.Message.route, content = { ChatroomListScreen(accountViewModel, navController) }) - composable(Route.Notification.route, content = { NotificationScreen(notifFeedViewModel, accountViewModel, navController) }) composable(Route.BlockedUsers.route, content = { HiddenUsersScreen(accountViewModel, navController) }) composable(Route.Bookmarks.route, content = { BookmarkListScreen(accountViewModel, navController) }) @@ -161,7 +188,10 @@ fun AppNavigation( } } - if (nextPage != null) { - navController.navigate(nextPage) + actionableNextPage?.let { + LaunchedEffect(it) { + navController.navigate(it) + } + actionableNextPage = null } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt index 99ea241af..ddbe4d307 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt @@ -24,9 +24,12 @@ sealed class Route( get() = route.substringBefore("?") object Home : Route( - route = "Home?scrollToTop={scrollToTop}", + route = "Home?scrollToTop={scrollToTop}&nip47={nip47}", icon = R.drawable.ic_home, - arguments = listOf(navArgument("scrollToTop") { type = NavType.BoolType; defaultValue = false }), + arguments = listOf( + navArgument("scrollToTop") { type = NavType.BoolType; defaultValue = false }, + navArgument("nip47") { type = NavType.StringType; nullable = true; defaultValue = null } + ), hasNewItems = { accountViewModel, cache -> homeHasNewItems(accountViewModel, cache) } ) @@ -37,8 +40,9 @@ sealed class Route( ) object Notification : Route( - route = "Notification", + route = "Notification?scrollToTop={scrollToTop}", icon = R.drawable.ic_notifications, + arguments = listOf(navArgument("scrollToTop") { type = NavType.BoolType; defaultValue = false }), hasNewItems = { accountViewModel, cache -> notificationHasNewItems(accountViewModel, cache) } ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index 1f329f01d..c4f2927e9 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -346,7 +346,7 @@ fun NoteComposeInner( } val pow = noteEvent.getPoWRank() - if (pow > 1) { + if (pow > 20) { DisplayPoW(pow) } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index 83e6599a1..74748ab49 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt @@ -303,10 +303,11 @@ fun ZapReaction( val zapsState by baseNote.live().zaps.observeAsState() val zappedNote = zapsState?.note + val zapMessage = "" var wantsToZap by remember { mutableStateOf(false) } var wantsToChangeZapAmount by remember { mutableStateOf(false) } - + var wantsToSetCustomZap by remember { mutableStateOf(false) } val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) val context = LocalContext.current val scope = rememberCoroutineScope() @@ -348,7 +349,7 @@ fun ZapReaction( baseNote, account.zapAmountChoices.first() * 1000, null, - "", + zapMessage, context, onError = { scope.launch { @@ -371,6 +372,9 @@ fun ZapReaction( }, onLongClick = { wantsToChangeZapAmount = true + }, + onDoubleClick = { + wantsToSetCustomZap = true } ) ) { @@ -403,6 +407,10 @@ fun ZapReaction( UpdateZapAmountDialog({ wantsToChangeZapAmount = false }, account = account) } + if (wantsToSetCustomZap) { + ZapCustomDialog({ wantsToSetCustomZap = false }, account = account, accountViewModel, baseNote) + } + if (zappedNote?.isZappedBy(account.userProfile()) == true) { zappingProgress = 1f Icon( @@ -531,7 +539,7 @@ fun ZapAmountChoicePopup( val accountState by accountViewModel.accountLiveData.observeAsState() val account = accountState?.account ?: return - + val zapMessage = "" val scope = rememberCoroutineScope() Popup( @@ -549,7 +557,7 @@ fun ZapAmountChoicePopup( baseNote, amountInSats * 1000, null, - "", + zapMessage, context, onError, onProgress @@ -574,7 +582,7 @@ fun ZapAmountChoicePopup( baseNote, amountInSats * 1000, null, - "", + zapMessage, context, onError, onProgress diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt index ef6e46360..7b0899c6b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt @@ -49,6 +49,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction @@ -114,20 +115,38 @@ class UpdateZapAmountViewModel : ViewModel() { account?.changeZapAmounts(amountSet) if (walletConnectRelay.text.isNotBlank() && walletConnectPubkey.text.isNotBlank()) { + val pubkeyHex = try { + decodePublicKey(walletConnectPubkey.text.trim()).toHexKey() + } catch (e: Exception) { + null + } + + val relayUrl = walletConnectRelay.text.ifBlank { null }?.let { + var addedWSS = + if (!it.startsWith("wss://") && !it.startsWith("ws://")) "wss://$it" else it + if (addedWSS.endsWith("/")) addedWSS = addedWSS.dropLast(1) + + addedWSS + } + val unverifiedPrivKey = walletConnectSecret.text.ifBlank { null } - val privKey = try { + val privKeyHex = try { unverifiedPrivKey?.let { decodePublicKey(it).toHexKey() } } catch (e: Exception) { null } - account?.changeZapPaymentRequest( - Nip47URI( - walletConnectPubkey.text, - walletConnectRelay.text.ifBlank { null }, - privKey + if (pubkeyHex != null) { + account?.changeZapPaymentRequest( + Nip47URI( + pubkeyHex, + relayUrl, + privKeyHex + ) ) - ) + } else { + account?.changeZapPaymentRequest(null) + } } else { account?.changeZapPaymentRequest(null) } @@ -147,17 +166,40 @@ class UpdateZapAmountViewModel : ViewModel() { walletConnectSecret.text != (account?.zapPaymentRequest?.secret ?: "") ) } + + fun updateNIP47(uri: String) { + val contact = Nip47.parse(uri) + if (contact != null) { + walletConnectPubkey = + TextFieldValue(contact.pubKeyHex) + walletConnectRelay = + TextFieldValue(contact.relayUri ?: "") + walletConnectSecret = + TextFieldValue(contact.secret ?: "") + } + } } @OptIn(ExperimentalLayoutApi::class) @Composable -fun UpdateZapAmountDialog(onClose: () -> Unit, account: Account) { +fun UpdateZapAmountDialog(onClose: () -> Unit, account: Account, nip47uri: String? = null) { val context = LocalContext.current val scope = rememberCoroutineScope() val postViewModel: UpdateZapAmountViewModel = viewModel() + val uri = LocalUriHandler.current LaunchedEffect(account) { postViewModel.load(account) + if (nip47uri != null) { + try { + postViewModel.updateNIP47(nip47uri) + } catch (e: IllegalArgumentException) { + scope.launch { + Toast.makeText(context, e.message, Toast.LENGTH_SHORT) + .show() + } + } + } } Dialog( @@ -281,6 +323,18 @@ fun UpdateZapAmountDialog(onClose: () -> Unit, account: Account) { stringResource(id = R.string.wallet_connect_service), Modifier.weight(1f) ) + + IconButton(onClick = { + runCatching { uri.openUri("https://nwc.getalby.com/apps/new?c=Amethyst") } + }) { + Icon( + painter = painterResource(R.drawable.alby), + null, + modifier = Modifier.size(24.dp), + tint = Color.Unspecified + ) + } + IconButton(onClick = { qrScanning = true }) { @@ -312,15 +366,7 @@ fun UpdateZapAmountDialog(onClose: () -> Unit, account: Account) { qrScanning = false if (!it.isNullOrEmpty()) { try { - val contact = Nip47.parse(it) - if (contact != null) { - postViewModel.walletConnectPubkey = - TextFieldValue(contact.pubKeyHex) - postViewModel.walletConnectRelay = - TextFieldValue(contact.relayUri ?: "") - postViewModel.walletConnectSecret = - TextFieldValue(contact.secret ?: "") - } + postViewModel.updateNIP47(it) } catch (e: IllegalArgumentException) { scope.launch { Toast.makeText(context, e.message, Toast.LENGTH_SHORT) @@ -370,7 +416,7 @@ fun UpdateZapAmountDialog(onClose: () -> Unit, account: Account) { onValueChange = { postViewModel.walletConnectRelay = it }, placeholder = { Text( - text = "relay.server.com", + text = "wss://relay.server.com", color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), maxLines = 1 ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt new file mode 100644 index 000000000..9cbb35b50 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt @@ -0,0 +1,193 @@ +package com.vitorpamplona.amethyst.ui.note + +import android.widget.Toast +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +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.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.actions.CloseButton +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +class ZapOptionstViewModel : ViewModel() { + private var account: Account? = null + + var customAmount by mutableStateOf(TextFieldValue("1000")) + var customMessage by mutableStateOf(TextFieldValue("")) + + fun load(account: Account) { + this.account = account + } + + fun canSend(): Boolean { + return value() != null + } + + fun value(): Long? { + return try { + customAmount.text.trim().toLongOrNull() + } catch (e: Exception) { + null + } + } + + fun cancel() { + } +} + +@Composable +fun ZapCustomDialog(onClose: () -> Unit, account: Account, accountViewModel: AccountViewModel, baseNote: Note) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val postViewModel: ZapOptionstViewModel = viewModel() + + LaunchedEffect(account) { + postViewModel.load(account) + } + + Dialog( + onDismissRequest = { onClose() }, + properties = DialogProperties( + dismissOnClickOutside = false, + usePlatformDefaultWidth = false + ) + ) { + Surface() { + Column(modifier = Modifier.padding(10.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + CloseButton(onCancel = { + postViewModel.cancel() + onClose() + }) + + ZapButton( + isActive = postViewModel.canSend() + ) { + scope.launch(Dispatchers.IO) { + accountViewModel.zap( + baseNote, + postViewModel.value()!! * 1000L, + postViewModel.customMessage.text, + context, + onError = { + scope.launch { + Toast + .makeText(context, it, Toast.LENGTH_SHORT).show() + } + }, + onProgress = { + scope.launch(Dispatchers.Main) { + } + } + ) + } + onClose() + } + } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 5.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + OutlinedTextField( + // stringResource(R.string.new_amount_in_sats + label = { Text(text = stringResource(id = R.string.amount_in_sats)) }, + value = postViewModel.customAmount, + onValueChange = { + postViewModel.customAmount = it + }, + keyboardOptions = KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.None, + keyboardType = KeyboardType.Number + ), + placeholder = { + Text( + text = "100, 1000, 5000", + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + ) + }, + singleLine = true, + modifier = Modifier + .padding(end = 10.dp) + .weight(1f) + ) + } + Spacer(modifier = Modifier.height(5.dp)) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 5.dp), + verticalAlignment = Alignment.CenterVertically + ) { + OutlinedTextField( + // stringResource(R.string.new_amount_in_sats + label = { Text(text = stringResource(id = R.string.custom_zaps_add_a_message)) }, + value = postViewModel.customMessage, + onValueChange = { + postViewModel.customMessage = it + }, + keyboardOptions = KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.None, + keyboardType = KeyboardType.Text + ), + placeholder = { + Text( + text = stringResource(id = R.string.custom_zaps_add_a_message_example), + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + ) + }, + singleLine = true, + modifier = Modifier + .padding(end = 10.dp) + .weight(1f) + ) + } + } + } + } +} + +@Composable +fun ZapButton(isActive: Boolean, onPost: () -> Unit) { + Button( + onClick = { onPost() }, + shape = RoundedCornerShape(20.dp), + colors = ButtonDefaults + .buttonColors( + backgroundColor = if (isActive) MaterialTheme.colors.primary else Color.Gray + ) + ) { + Text(text = "⚡Zap ", color = Color.White) + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedView.kt index 0744d4b1d..ed5e46c62 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedView.kt @@ -13,6 +13,7 @@ import androidx.compose.material.pullrefresh.PullRefreshIndicator import androidx.compose.material.pullrefresh.pullRefresh import androidx.compose.material.pullrefresh.rememberPullRefreshState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -33,7 +34,14 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @OptIn(ExperimentalMaterialApi::class) @Composable -fun CardFeedView(viewModel: CardFeedViewModel, accountViewModel: AccountViewModel, navController: NavController, routeForLastRead: String) { +fun CardFeedView( + viewModel: CardFeedViewModel, + accountViewModel: AccountViewModel, + navController: NavController, + routeForLastRead: String, + scrollStateKey: String? = null, + scrollToTop: Boolean = false +) { val feedState by viewModel.feedContent.collectAsState() var refreshing by remember { mutableStateOf(false) } @@ -57,10 +65,12 @@ fun CardFeedView(viewModel: CardFeedViewModel, accountViewModel: AccountViewMode is CardFeedState.Loaded -> { refreshing = false FeedLoaded( - state, - accountViewModel, - navController, - routeForLastRead + state = state, + accountViewModel = accountViewModel, + navController = navController, + routeForLastRead = routeForLastRead, + scrollStateKey = scrollStateKey, + scrollToTop = scrollToTop ) } CardFeedState.Loading -> { @@ -79,9 +89,21 @@ private fun FeedLoaded( state: CardFeedState.Loaded, accountViewModel: AccountViewModel, navController: NavController, - routeForLastRead: String + routeForLastRead: String, + scrollStateKey: String?, + scrollToTop: Boolean = false ) { - val listState = rememberLazyListState() + val listState = if (scrollStateKey != null) { + rememberForeverLazyListState(scrollStateKey) + } else { + rememberLazyListState() + } + + if (scrollToTop) { + LaunchedEffect(Unit) { + listState.scrollToItem(index = 0) + } + } LazyColumn( contentPadding = PaddingValues( diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedViewModel.kt index 34337677e..4e547522d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedViewModel.kt @@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.ui.screen import android.util.Log import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel +import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.LocalCacheState import com.vitorpamplona.amethyst.model.Note @@ -32,6 +33,7 @@ open class CardFeedViewModel(val dataSource: FeedFilter) : ViewModel() { private val _feedContent = MutableStateFlow(CardFeedState.Loading) val feedContent = _feedContent.asStateFlow() + private var lastAccount: Account? = null private var lastNotes: List? = null private fun refresh() { @@ -45,18 +47,21 @@ open class CardFeedViewModel(val dataSource: FeedFilter) : ViewModel() { private fun refreshSuspended() { val notes = dataSource.loadTop() - val lastNotesCopy = lastNotes + val thisAccount = (dataSource as? NotificationFeedFilter)?.account + val lastNotesCopy = if (thisAccount == lastAccount) lastNotes else null val oldNotesState = feedContent.value if (lastNotesCopy != null && oldNotesState is CardFeedState.Loaded) { val newCards = convertToCard(notes.minus(lastNotesCopy)) if (newCards.isNotEmpty()) { lastNotes = notes + lastAccount = (dataSource as? NotificationFeedFilter)?.account updateFeed((oldNotesState.feed.value + newCards).distinctBy { it.id() }.sortedBy { it.createdAt() }.reversed()) } } else { val cards = convertToCard(notes) lastNotes = notes + lastAccount = (dataSource as? NotificationFeedFilter)?.account updateFeed(cards) } } @@ -100,14 +105,21 @@ open class CardFeedViewModel(val dataSource: FeedFilter) : ViewModel() { } val allBaseNotes = zapsPerEvent.keys + boostsPerEvent.keys + reactionsPerEvent.keys - val multiCards = allBaseNotes.map { - MultiSetCard( - it, - boostsPerEvent.get(it) ?: emptyList(), - reactionsPerEvent.get(it) ?: emptyList(), - zapsPerEvent.get(it) ?: emptyMap() - ) - } + val multiCards = allBaseNotes.map { baseNote -> + val boostsInCard = boostsPerEvent[baseNote] ?: emptyList() + val reactionsInCard = reactionsPerEvent[baseNote] ?: emptyList() + val zapsInCard = zapsPerEvent[baseNote] ?: emptyMap() + + val singleList = (boostsInCard + zapsInCard.values + reactionsInCard).sortedBy { it.createdAt() }.reversed() + singleList.chunked(50).map { chunk -> + MultiSetCard( + baseNote, + boostsInCard.filter { it in chunk }, + reactionsInCard.filter { it in chunk }, + zapsInCard.filter { it.value in chunk } + ) + } + }.flatten() val textNoteCards = notes.filter { it.event !is ReactionEvent && it.event !is RepostEvent && it.event !is LnZapEvent }.map { if (it.event is PrivateDmEvent) { @@ -160,7 +172,13 @@ open class CardFeedViewModel(val dataSource: FeedFilter) : ViewModel() { LocalCache.live.observeForever(cacheListener) } + fun clear() { + lastAccount = null + lastNotes = null + } + override fun onCleared() { + clear() LocalCache.live.removeObserver(cacheListener) super.onCleared() } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LazyListState.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LazyListState.kt index 0a4a2d5cf..ebf122ee5 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LazyListState.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LazyListState.kt @@ -11,6 +11,7 @@ private data class ScrollState(val index: Int, val scrollOffset: Int) object ScrollStateKeys { const val GLOBAL_SCREEN = "Global" + const val NOTIFICATION_SCREEN = "Notifications" val HOME_FOLLOWS = Route.Home.base + "Follows" val HOME_REPLIES = Route.Home.base + "FollowsReplies" } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt index 73bdf39b5..f1c38eaaa 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt @@ -61,6 +61,7 @@ import com.vitorpamplona.amethyst.ui.note.* import com.vitorpamplona.amethyst.ui.note.BadgeDisplay import com.vitorpamplona.amethyst.ui.note.BlankNote import com.vitorpamplona.amethyst.ui.note.DisplayFollowingHashtagsInPost +import com.vitorpamplona.amethyst.ui.note.DisplayPoW import com.vitorpamplona.amethyst.ui.note.DisplayReward import com.vitorpamplona.amethyst.ui.note.DisplayUncitedHashtags import com.vitorpamplona.amethyst.ui.note.HiddenNote @@ -286,6 +287,11 @@ fun NoteMaster( if (baseReward != null) { DisplayReward(baseReward, baseNote, account, navController) } + + val pow = noteEvent.getPoWRank() + if (pow > 20) { + DisplayPoW(pow) + } } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index a82deb3bb..73330c690 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -60,7 +60,7 @@ class AccountViewModel(private val account: Account) : ViewModel() { return } - val zapRequest = account.createZapRequestFor(note, pollOption) + val zapRequest = account.createZapRequestFor(note, pollOption, message) onProgress(0.10f) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HomeScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HomeScreen.kt index 15676ba3c..c191de3aa 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HomeScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HomeScreen.kt @@ -11,7 +11,11 @@ import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.res.stringResource @@ -28,6 +32,7 @@ import com.vitorpamplona.amethyst.service.NostrHomeDataSource import com.vitorpamplona.amethyst.ui.dal.HomeConversationsFeedFilter import com.vitorpamplona.amethyst.ui.dal.HomeNewThreadFeedFilter import com.vitorpamplona.amethyst.ui.navigation.Route +import com.vitorpamplona.amethyst.ui.note.UpdateZapAmountDialog import com.vitorpamplona.amethyst.ui.screen.FeedView import com.vitorpamplona.amethyst.ui.screen.NostrHomeFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrHomeRepliesFeedViewModel @@ -42,10 +47,12 @@ fun HomeScreen( accountViewModel: AccountViewModel, navController: NavController, pagerState: PagerState, - scrollToTop: Boolean = false + scrollToTop: Boolean = false, + nip47: String? = null ) { val coroutineScope = rememberCoroutineScope() val account = accountViewModel.accountLiveData.value?.account ?: return + var wantsToAddNip47 by remember { mutableStateOf(nip47) } LaunchedEffect(accountViewModel) { HomeNewThreadFeedFilter.account = account @@ -55,6 +62,10 @@ fun HomeScreen( repliesFeedViewModel.invalidateData() } + if (wantsToAddNip47 != null) { + UpdateZapAmountDialog({ wantsToAddNip47 = null }, account = account, wantsToAddNip47) + } + val lifeCycleOwner = LocalLifecycleOwner.current DisposableEffect(accountViewModel) { val observer = LifecycleEventObserver { _, event -> diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NotificationScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NotificationScreen.kt index 5411488c9..96149588d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NotificationScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NotificationScreen.kt @@ -18,12 +18,22 @@ import com.vitorpamplona.amethyst.ui.dal.NotificationFeedFilter import com.vitorpamplona.amethyst.ui.navigation.Route import com.vitorpamplona.amethyst.ui.screen.CardFeedView import com.vitorpamplona.amethyst.ui.screen.NotificationViewModel +import com.vitorpamplona.amethyst.ui.screen.ScrollStateKeys @Composable -fun NotificationScreen(notifFeedViewModel: NotificationViewModel, accountViewModel: AccountViewModel, navController: NavController) { +fun NotificationScreen( + notifFeedViewModel: NotificationViewModel, + accountViewModel: AccountViewModel, + navController: NavController, + scrollToTop: Boolean = false +) { val accountState by accountViewModel.accountLiveData.observeAsState() val account = accountState?.account ?: return + if (scrollToTop) { + notifFeedViewModel.clear() + } + LaunchedEffect(accountViewModel) { NotificationFeedFilter.account = account notifFeedViewModel.invalidateData() @@ -48,7 +58,14 @@ fun NotificationScreen(notifFeedViewModel: NotificationViewModel, accountViewMod Column( modifier = Modifier.padding(vertical = 0.dp) ) { - CardFeedView(notifFeedViewModel, accountViewModel = accountViewModel, navController, Route.Notification.route) + CardFeedView( + viewModel = notifFeedViewModel, + accountViewModel = accountViewModel, + navController = navController, + routeForLastRead = Route.Notification.base, + scrollStateKey = ScrollStateKeys.NOTIFICATION_SCREEN, + scrollToTop = scrollToTop + ) } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt index 82d515f7e..88159e4d5 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt @@ -430,12 +430,13 @@ private fun DrawAdditionalInfo(baseUser: User, account: Account, accountViewMode fontSize = 25.sp ) } - + } + Row(verticalAlignment = Alignment.CenterVertically) { user.bestUsername()?.let { Text( "@$it", color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), - modifier = Modifier.padding(top = 1.dp, bottom = 1.dp, start = 5.dp) + modifier = Modifier.padding(top = 1.dp, bottom = 1.dp) ) } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/SearchScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/SearchScreen.kt index 319cd8383..5337064d8 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/SearchScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/SearchScreen.kt @@ -140,6 +140,9 @@ private fun SearchBar(accountViewModel: AccountViewModel, navController: NavCont val onlineSearch = NostrSearchEventOrUserDataSource + val dbState = LocalCache.live.observeAsState() + val db = dbState.value ?: return + val isTrailingIconVisible by remember { derivedStateOf { searchValue.isNotBlank() @@ -151,6 +154,18 @@ private fun SearchBar(accountViewModel: AccountViewModel, navController: NavCont CoroutineChannel(CoroutineChannel.CONFLATED) } + LaunchedEffect(db) { + if (searchValue.length > 1) { + withContext(Dispatchers.IO) { + searchResults.value = LocalCache.findUsersStartingWith(searchValue) + searchResultsNotes.value = + LocalCache.findNotesStartingWith(searchValue).sortedBy { it.createdAt() } + .reversed() + searchResultsChannels.value = LocalCache.findChannelsStartingWith(searchValue) + } + } + } + LaunchedEffect(Unit) { // Wait for text changes to stop for 300 ms before firing off search. withContext(Dispatchers.IO) { @@ -161,7 +176,7 @@ private fun SearchBar(accountViewModel: AccountViewModel, navController: NavCont .collectLatest { hashtagResults.value = findHashtags(it) - if (it.removePrefix("npub").removePrefix("note").length >= 4) { + if (it.length >= 4) { onlineSearch.search(it.trim()) } diff --git a/app/src/main/res/drawable/alby.xml b/app/src/main/res/drawable/alby.xml new file mode 100644 index 000000000..cb036c35e --- /dev/null +++ b/app/src/main/res/drawable/alby.xml @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index 6b7b6b0b0..81296d243 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -22,6 +22,7 @@ A felhasználó PubKulcs másolása A bejegyzés azonosítójának másolása Közvetít + Törlés Kérése Spam / Csalás bejelentés Megszemélyesítés bejelentés @@ -72,6 +73,7 @@ Nem sikerült feltölteni a képet Csomópont Címe Hozzászólások + Bájtok Hibák Hírfolyamod Privát Üzenetek listája @@ -119,6 +121,7 @@ Közvetlen üzenet küldése Szerkeszti a felhasználó metaadatait Követés + Visszakövetem Tiltás feloldása Felhasználói azonosító másolása Felhasználó feloldása @@ -172,6 +175,7 @@ Az összes ismert megjelölése olvasottként Az összes új megjelölése olvasottként Összes megjelölése olvasottként + Biztonsági Kulcsok ## Kulcs- és biztonsági mentési tippek \n\nFiókját titkos kulcs védi. A kulcs egy hosszú véletlenszerű karakterlánc, amely **nsec1**-el kezdődik. Bárki, aki az Ön titkos kulcsához hozzáfér, az Ön személyazonosságának használatával bármilyen tartalmat közzétehet. @@ -187,4 +191,81 @@ "Kitűző %1$s" Új Jelvényt kapott Kitüntetésben részesült + Bejegyzés vágólapra helyezve + A szerző @npub-ja vágólapra helyezve + A bejegyzés azonosítója (@note1) vágólapra helyezve + Szöveg Kijelölése + Github Gist w/ Proof + Telegram + Mastodon Post ID w/ Proof + Twitter Status w/ Proof + https://gist.github.com/<user>/<gist> + https://t.me/<proof post> + https://<server>/<user>/<proof post> + https://twitter.com/<user>/status/<proof post> + "<Nem sikerült a privát üzenetet visszafejteni>\n\nTéged egy privát/titkosított beszélgetésben %1$s és %2$s idéztek." + Új Fiók Hozzáadása + Fiókok + Fiók Kiválasztása + Új Fiók Hozzáadása + Aktív fiók + Tartalmaz privát kulcsot + Csak olvasásra, nincs privát kulcs + Vissza + Kiválaszt + Link megosztása + Megosztás + Szerző azonosító + Bejegyzés azonosító + Szöveg Másolása + Törlés + Kikövetés + Követem + Törlés Kérése + Az Amethyst kérni fogja, hogy töröljék a bejegyzésed azokról a csomópontokról, amelyekhez jelenleg csatlakozol. Nincs garancia arra, hogy a bejegyzésed ezekről a csomópontokról vagy más közvetítőkből (ahol tárolni lehet) véglegesen törlődik. + Tiltás + Törlés + Tiltás + Jelentés + Törlés + Ne mutasd újra + Spam vagy átverés + Trágárság vagy gyűlöletkeltő magatartás + Rosszindulatú megszemélyesítés + Meztelenség vagy pornográf tartalom + Illegális viselkedés + Az alkalmazásban egy felhasználó letiltása minden hozzá köthető tartalmat elrejt. Jegyzeteid továbbra is nyilvánosan megtekinthetők, beleértve a letiltott személyek számára is. A letiltott felhasználók a Biztonsági szűrők felületen jelennek meg. + + Visszaélés jelentése + Minden közzétett jelentés nyilvánosan látható lesz. + Opcionálisan a jelentéséhez további kontextust addhat… + További kontextus + Ok + Válassz egy okot… + Jelentés Elküldése + Tiltás és Jelentés + Tiltás + + Könyvjelzők + Privát Könyvjelzők + Publikus Könyvjelzők + Hozzáadás a Privát Könyvjelzőimhez + Hozzáadás a Publikus Könyvjelzőimhez + Törlés a Privát Könyvjelzőimből + Törlés a Publikus Könyvjelzőimből + + Wallet Connect Szolgáltatás + Engedélyezed a Nostr Secret-et, hogy az alkalmazás elhagyása nélkül Zap-ot küldjön. Tartsa a titkot biztonságban, és ha lehetséges, privát csomópontot használjon + Wallet Connect Publikus Kulcs + Wallet Connect Csomópont + Wallet Connect Titok + Titkos Kulcs Megjelenítése + nsec / hex privát kulcs + + Hozzájárulás összege sats-ban + + "%1$s esemény keresése" + + Nyilvános üzenet hozzáadása + Köszönöm a kemény munkát! diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e3b99fe47..40e656b03 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -285,4 +285,7 @@ Only one vote per user is allowed on this type of poll "Looking for Event %1$s" + + Add a public message + Thank you for all your work! diff --git a/build.gradle b/build.gradle index 26862ebcf..ee67b5b0c 100644 --- a/build.gradle +++ b/build.gradle @@ -1,11 +1,12 @@ buildscript { ext { - fragment_version = "1.5.5" - lifecycle_version = '2.6.0' - compose_ui_version = '1.4.0-rc01' + fragment_version = "1.5.6" + lifecycle_version = '2.6.1' + compose_ui_version = '1.5.0-alpha01' nav_version = "2.5.3" room_version = "2.4.3" - accompanist_version = "0.28.0" + accompanist_version = '0.30.0' + coil_version = '2.3.0' } }// Top-level build file where you can add configuration options common to all sub-projects/modules. plugins {