Compare commits

...

21 Commits

Author SHA1 Message Date
Vitor Pamplona
a38087a9b9 v0.14.2 2023-02-10 09:13:45 -05:00
Vitor Pamplona
29dc677148 Allows the user to leave and join their own groups 2023-02-10 09:13:01 -05:00
Vitor Pamplona
50bc55e84b More Performant UpdateFollows 2023-02-09 17:52:18 -05:00
Vitor Pamplona
ae8d39df92 Download only metadata in the Search for authors 2023-02-09 16:42:30 -05:00
Vitor Pamplona
09919e99b9 Fixes: https://github.com/vitorpamplona/amethyst/issues/91 2023-02-09 15:25:32 -05:00
Vitor Pamplona
5069c30a4a Fixes https://github.com/vitorpamplona/amethyst/issues/103 Doesn't do anything if the content in the field is not a URI. 2023-02-09 15:11:20 -05:00
Vitor Pamplona
4097263c12 Activating Proguard 2023-02-09 15:02:13 -05:00
Vitor Pamplona
88e91e37fa Fixing the height of the nav bar icon 2023-02-09 14:56:54 -05:00
Vitor Pamplona
a003e111eb Fixing QR Code screen for smaller screens. 2023-02-09 14:45:00 -05:00
Vitor Pamplona
4cde2fe8e6 Refactoring of RelaySetupInfo away from ModelView 2023-02-09 14:25:58 -05:00
Vitor Pamplona
ffe5e79474 Making sure once proguard is used, it doesn't break json serialization 2023-02-09 14:24:56 -05:00
Vitor Pamplona
19a9e7a949 language-id seems necessary for some phones. unsure why 2023-02-09 14:24:28 -05:00
Vitor Pamplona
757e566774 Fixes permissions issue in Image Upload 2023-02-09 11:22:49 -05:00
Vitor Pamplona
7fff535b74 v0.14.1:
- Save image
- LNURL edit in the profile
- Bugfix crashing the Lightning Tips with non numeric values in the amount.
2023-02-09 10:42:08 -05:00
Vitor Pamplona
65ef34511a Adding LNURL setup in the User Metadata 2023-02-09 10:36:43 -05:00
Vitor Pamplona
644c6b4d76 Merge remote-tracking branch 'origin/main' 2023-02-09 09:47:34 -05:00
Vitor Pamplona
dec17e162e Avoids crashing on a non-numeric amount in Lightning Tips 2023-02-09 09:47:23 -05:00
Vitor Pamplona
df49c63936 Merge pull request #99 from Radiokot/feature/save_images
Add Save option to the image view screen
2023-02-09 09:22:46 -05:00
Vitor Pamplona
f0d4ad38a5 Merge pull request #98 from bleetube/offchain.pub
Public relay name change
2023-02-09 09:22:27 -05:00
Oleg Koretsky
3fde6b4b1f Add Save option to the image view screen 2023-02-08 22:06:12 +02:00
Brian Lee
5102a15532 Public relay name change 2023-02-08 12:05:56 -08:00
23 changed files with 504 additions and 182 deletions

View File

@@ -11,8 +11,8 @@ android {
applicationId "com.vitorpamplona.amethyst"
minSdk 26
targetSdk 33
versionCode 40
versionName "0.14.0"
versionCode 42
versionName "0.14.2"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@@ -22,8 +22,8 @@ android {
buildTypes {
release {
//minifyEnabled false
//proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
minifyEnabled true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
resValue "string", "app_name", "@string/app_name_release"
}
debug {
@@ -130,6 +130,7 @@ dependencies {
// Use this dependency to use the translate text
implementation 'com.google.mlkit:translate:17.0.1'
implementation 'com.google.mlkit:language-id:17.0.4'
implementation 'com.google.android.gms:play-services-mlkit-language-id:17.0.0'
testImplementation 'junit:junit:4.13.2'

View File

@@ -26,6 +26,10 @@
# For the NostrPostr library
-keep class nostr.postr.** { *; }
-keep class nostr.postr.events.** { *; }
# Json parsing
-keep class com.google.gson.reflect.** { *; }
-keep class * extends com.google.gson.reflect.TypeToken
-keep public class * implements java.lang.reflect.Type
-keep class com.vitorpamplona.amethyst.model.** { *; }
-keep class com.vitorpamplona.amethyst.service.** { *; }

View File

@@ -4,8 +4,13 @@
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.CAMERA" />
<!-- Used for SDK < 29 -->
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
tools:ignore="ScopedStorage" />
<application
android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules"

View File

@@ -4,16 +4,10 @@ import android.content.Context
import com.google.gson.GsonBuilder
import com.google.gson.reflect.TypeToken
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.DefaultChannels
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.toByteArray
import com.vitorpamplona.amethyst.ui.actions.NewRelayListViewModel
import com.vitorpamplona.amethyst.ui.navigation.Route
import com.vitorpamplona.amethyst.model.RelaySetupInfo
import java.util.Locale
import nostr.postr.Persona
import nostr.postr.events.ContactListEvent
import nostr.postr.events.Event
import nostr.postr.toHex
class LocalPreferences(context: Context) {
@@ -52,8 +46,8 @@ class LocalPreferences(context: Context) {
val hiddenUsers = getStringSet("hidden_users", emptySet()) ?: setOf()
val localRelays = gson.fromJson(
getString("relays", "[]"),
object : TypeToken<Set<NewRelayListViewModel.Relay>>() {}.type
) ?: setOf<NewRelayListViewModel.Relay>()
object : TypeToken<Set<RelaySetupInfo>>() {}.type
) ?: setOf<RelaySetupInfo>()
val dontTranslateFrom = getStringSet("dontTranslateFrom", null) ?: setOf()
val translateTo = getString("translateTo", null) ?: Locale.getDefault().language

View File

@@ -1,8 +1,6 @@
package com.vitorpamplona.amethyst.model
import android.content.res.Resources
import androidx.compose.material.DropdownMenuItem
import androidx.compose.material.Text
import androidx.core.os.ConfigurationCompat
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.service.relays.Constants
@@ -16,7 +14,6 @@ import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.service.relays.RelayPool
import com.vitorpamplona.amethyst.ui.actions.NewRelayListViewModel
import java.util.Date
import java.util.Locale
import java.util.concurrent.atomic.AtomicBoolean
@@ -53,7 +50,7 @@ class Account(
val loggedIn: Persona,
var followingChannels: Set<String> = DefaultChannels,
var hiddenUsers: Set<String> = setOf(),
var localRelays: Set<NewRelayListViewModel.Relay> = Constants.defaultRelays.toSet(),
var localRelays: Set<RelaySetupInfo> = Constants.defaultRelays.toSet(),
var dontTranslateFrom: Set<String> = getLanguagesSpokenByUser(),
var translateTo: String = Locale.getDefault().language
) {
@@ -435,7 +432,7 @@ class Account(
innerReports).toSet()
}
fun saveRelayList(value: List<NewRelayListViewModel.Relay>) {
fun saveRelayList(value: List<RelaySetupInfo>) {
localRelays = value.toSet()
sendNewRelayList(value.associate { it.url to ContactListEvent.ReadWrite(it.read, it.write) } )
}

View File

@@ -17,6 +17,7 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import com.vitorpamplona.amethyst.service.relays.Relay
import java.util.Date
import java.util.concurrent.atomic.AtomicBoolean
import java.util.regex.Matcher
import java.util.regex.Pattern
import nostr.postr.events.Event
@@ -194,17 +195,19 @@ class Note(val idHex: String) {
class NoteLiveData(val note: Note): LiveData<NoteState>(NoteState(note)) {
// Refreshes observers in batches.
var handlerWaiting = false
var handlerWaiting = AtomicBoolean()
@Synchronized
fun invalidateData() {
if (handlerWaiting) return
if (!hasActiveObservers()) return
if (handlerWaiting.getAndSet(true)) return
handlerWaiting = true
val scope = CoroutineScope(Job() + Dispatchers.Default)
handlerWaiting.set(true)
val scope = CoroutineScope(Job() + Dispatchers.Main)
scope.launch {
delay(100)
refresh()
handlerWaiting = false
handlerWaiting.set(false)
}
}

View File

@@ -0,0 +1,13 @@
package com.vitorpamplona.amethyst.model
import com.vitorpamplona.amethyst.service.relays.FeedType
data class RelaySetupInfo(
val url: String,
val read: Boolean,
val write: Boolean,
val errorCount: Int = 0,
val downloadCount: Int = 0,
val uploadCount: Int = 0,
val feedTypes: Set<FeedType>
)

View File

@@ -10,6 +10,9 @@ import com.vitorpamplona.amethyst.ui.note.toShortenHex
import fr.acinq.secp256k1.Hex
import java.util.Collections
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.time.ExperimentalTime
import kotlin.time.measureTimedValue
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -81,7 +84,11 @@ class User(val pubkeyHex: String) {
liveFollows.invalidateData()
user.liveFollows.invalidateData()
listeners.forEach {
updateSubscribers {
it.onFollowsChange()
}
user.updateSubscribers {
it.onFollowsChange()
}
}
@@ -95,6 +102,40 @@ class User(val pubkeyHex: String) {
updateSubscribers {
it.onFollowsChange()
}
user.updateSubscribers {
it.onFollowsChange()
}
}
fun follow(users: Set<User>, followedAt: Long) {
follows = follows + users
users.forEach {
it.followers = it.followers + this
it.liveFollows.invalidateData()
it.updateSubscribers {
it.onFollowsChange()
}
}
liveFollows.invalidateData()
updateSubscribers {
it.onFollowsChange()
}
}
fun unfollow(users: Set<User>) {
follows = follows - users
users.forEach {
it.followers = it.followers - this
it.liveFollows.invalidateData()
it.updateSubscribers {
it.onFollowsChange()
}
}
liveFollows.invalidateData()
updateSubscribers {
it.onFollowsChange()
}
}
fun addTaggedPost(note: Note) {
@@ -164,17 +205,13 @@ class User(val pubkeyHex: String) {
updateSubscribers { it.onNewRelayInfo() }
liveRelayInfo.invalidateData()
}
fun updateFollows(newFollows: Set<User>, updateAt: Long) {
val toBeAdded = newFollows - follows
val toBeRemoved = follows - newFollows
toBeAdded.forEach {
follow(it, updateAt)
}
toBeRemoved.forEach {
unfollow(it)
}
follow(toBeAdded, updateAt)
unfollow(toBeRemoved)
updatedFollowsAt = updateAt
}
@@ -239,19 +276,20 @@ class User(val pubkeyHex: String) {
}
// Refreshes observers in batches.
var modelHandlerWaiting = false
var modelHandlerWaiting = AtomicBoolean()
@Synchronized
fun updateSubscribers(on: (Listener) -> Unit) {
if (modelHandlerWaiting) return
if (modelHandlerWaiting.getAndSet(true)) return
modelHandlerWaiting = true
modelHandlerWaiting.set(true)
val scope = CoroutineScope(Job() + Dispatchers.Default)
scope.launch {
delay(100)
listeners.forEach {
on(it)
}
modelHandlerWaiting = false
modelHandlerWaiting.set(false)
}
}
@@ -292,17 +330,19 @@ class UserMetadata {
class UserLiveData(val user: User): LiveData<UserState>(UserState(user)) {
// Refreshes observers in batches.
var handlerWaiting = false
var handlerWaiting = AtomicBoolean()
@Synchronized
fun invalidateData() {
if (handlerWaiting) return
if (!hasActiveObservers()) return
if (handlerWaiting.getAndSet(true)) return
handlerWaiting = true
handlerWaiting.set(true)
val scope = CoroutineScope(Job() + Dispatchers.Main)
scope.launch {
delay(100)
refresh()
handlerWaiting = false
handlerWaiting.set(false)
}
}

View File

@@ -2,10 +2,13 @@ package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.decodePublicKey
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import nostr.postr.JsonFilter
import nostr.postr.bechToBytes
import nostr.postr.events.MetadataEvent
import nostr.postr.events.TextNoteEvent
import nostr.postr.toHex
object NostrSearchEventOrUserDataSource: NostrDataSource<Note>("SingleEventFeed") {
@@ -21,13 +24,16 @@ object NostrSearchEventOrUserDataSource: NostrDataSource<Note>("SingleEventFeed"
TypedFilter(
types = FeedType.values().toSet(),
filter = JsonFilter(
ids = listOfNotNull(hexToWatch)
)),
ids = listOfNotNull(hexToWatch)
)
),
TypedFilter(
types = FeedType.values().toSet(),
filter = JsonFilter(
authors = listOfNotNull(hexToWatch)
))
kinds = listOf(MetadataEvent.kind),
authors = listOfNotNull(hexToWatch)
)
)
)
}

View File

@@ -1,6 +1,6 @@
package com.vitorpamplona.amethyst.service.relays
import com.vitorpamplona.amethyst.ui.actions.NewRelayListViewModel
import com.vitorpamplona.amethyst.model.RelaySetupInfo
object Constants {
val activeTypes = setOf(FeedType.FOLLOWS, FeedType.PRIVATE_DMS)
@@ -14,15 +14,15 @@ object Constants {
val defaultRelays = arrayOf(
// Free relays
NewRelayListViewModel.Relay("wss://nostr.bitcoiner.social", read = true, write = true, feedTypes = activeTypes),
NewRelayListViewModel.Relay("wss://relay.nostr.bg", read = true, write = true, feedTypes = activeTypes),
NewRelayListViewModel.Relay("wss://relay.snort.social", read = true, write = true, feedTypes = activeTypes),
NewRelayListViewModel.Relay("wss://relay.damus.io", read = true, write = true, feedTypes = activeTypes),
NewRelayListViewModel.Relay("wss://nostr.oxtr.dev", read = true, write = true, feedTypes = activeTypes),
NewRelayListViewModel.Relay("wss://nostr-pub.wellorder.net", read = true, write = true, feedTypes = activeTypes),
NewRelayListViewModel.Relay("wss://nostr.mom", read = true, write = true, feedTypes = activeTypes),
NewRelayListViewModel.Relay("wss://no.str.cr", read = true, write = true, feedTypes = activeTypes),
NewRelayListViewModel.Relay("wss://nos.lol", read = true, write = true, feedTypes = activeTypes),
RelaySetupInfo("wss://nostr.bitcoiner.social", read = true, write = true, feedTypes = activeTypes),
RelaySetupInfo("wss://relay.nostr.bg", 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),
// Less Reliable
//NewRelayListViewModel.Relay("wss://nostr.orangepill.dev", read = true, write = true, feedTypes = activeTypes),
@@ -35,14 +35,14 @@ object Constants {
//NewRelayListViewModel.Relay("wss://brb.io", read = true, write = true, feedTypes = activeTypes),
// Paid relays
NewRelayListViewModel.Relay("wss://relay.nostr.com.au", read = true, write = false, feedTypes = activeTypesGlobalChats),
NewRelayListViewModel.Relay("wss://eden.nostr.land", read = true, write = false, feedTypes = activeTypesGlobalChats),
NewRelayListViewModel.Relay("wss://nostr.milou.lol", read = true, write = false, feedTypes = activeTypesGlobalChats),
NewRelayListViewModel.Relay("wss://puravida.nostr.land", read = true, write = false, feedTypes = activeTypesGlobalChats),
NewRelayListViewModel.Relay("wss://nostr.wine", read = true, write = false, feedTypes = activeTypesGlobalChats),
NewRelayListViewModel.Relay("wss://nostr.inosta.cc", read = true, write = false, feedTypes = activeTypesGlobalChats),
NewRelayListViewModel.Relay("wss://atlas.nostr.land", read = true, write = false, feedTypes = activeTypesGlobalChats),
NewRelayListViewModel.Relay("wss://relay.orangepill.dev", read = true, write = false, feedTypes = activeTypesGlobalChats),
NewRelayListViewModel.Relay("wss://relay.nostrati.com", read = true, write = false, feedTypes = activeTypesGlobalChats),
RelaySetupInfo("wss://relay.nostr.com.au", read = true, write = false, feedTypes = activeTypesGlobalChats),
RelaySetupInfo("wss://eden.nostr.land", read = true, write = false, feedTypes = activeTypesGlobalChats),
RelaySetupInfo("wss://nostr.milou.lol", read = true, write = false, feedTypes = activeTypesGlobalChats),
RelaySetupInfo("wss://puravida.nostr.land", read = true, write = false, feedTypes = activeTypesGlobalChats),
RelaySetupInfo("wss://nostr.wine", read = true, write = false, feedTypes = activeTypesGlobalChats),
RelaySetupInfo("wss://nostr.inosta.cc", read = true, write = false, feedTypes = activeTypesGlobalChats),
RelaySetupInfo("wss://atlas.nostr.land", read = true, write = false, feedTypes = activeTypesGlobalChats),
RelaySetupInfo("wss://relay.orangepill.dev", read = true, write = false, feedTypes = activeTypesGlobalChats),
RelaySetupInfo("wss://relay.nostrati.com", read = true, write = false, feedTypes = activeTypesGlobalChats),
)
}

View File

@@ -0,0 +1,147 @@
package com.vitorpamplona.amethyst.ui.actions
import android.content.ContentResolver
import android.content.ContentValues
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import androidx.annotation.RequiresApi
import androidx.core.net.toUri
import okhttp3.*
import okio.BufferedSource
import okio.IOException
import okio.sink
import java.io.File
object ImageSaver {
/**
* Saves the image to the gallery.
* May require a storage permission.
*
* @see PICTURES_SUBDIRECTORY
*/
fun saveImage(
url: String,
context: Context,
onSuccess: () -> Any?,
onError: (Throwable) -> Any?,
) {
val client = OkHttpClient.Builder().build()
val request = Request.Builder()
.get()
.url(url)
.build()
client.newCall(request).enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
e.printStackTrace()
onError(e)
}
override fun onResponse(call: Call, response: Response) {
try {
check(response.isSuccessful)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val contentType = response.header("Content-Type")
checkNotNull(contentType) {
"Can't find out the content type"
}
saveContentQ(
displayName = File(url).nameWithoutExtension,
contentType = contentType,
contentSource = response.body.source(),
contentResolver = context.contentResolver,
)
} else {
saveContentDefault(
fileName = File(url).name,
contentSource = response.body.source(),
context = context,
)
}
onSuccess()
} catch (e: Exception) {
e.printStackTrace()
onError(e)
}
}
})
}
@RequiresApi(Build.VERSION_CODES.Q)
private fun saveContentQ(
displayName: String,
contentType: String,
contentSource: BufferedSource,
contentResolver: ContentResolver,
) {
val contentValues = ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, displayName)
put(MediaStore.MediaColumns.MIME_TYPE, contentType)
put(
MediaStore.MediaColumns.RELATIVE_PATH,
Environment.DIRECTORY_PICTURES + File.separatorChar + PICTURES_SUBDIRECTORY
)
}
val uri =
contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues)
checkNotNull(uri) {
"Can't insert the new content"
}
try {
val outputStream = contentResolver.openOutputStream(uri)
checkNotNull(outputStream) {
"Can't open the content output stream"
}
outputStream.use {
contentSource.readAll(it.sink())
}
} catch (e: Exception) {
contentResolver.delete(uri, null, null)
throw e
}
}
private fun saveContentDefault(
fileName: String,
contentSource: BufferedSource,
context: Context,
) {
val subdirectory = File(
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES),
PICTURES_SUBDIRECTORY
)
if (!subdirectory.exists()) {
subdirectory.mkdirs()
}
val outputFile = File(subdirectory, fileName)
outputFile
.outputStream()
.use {
contentSource.readAll(it.sink())
}
// Call the media scanner manually, so the image
// appears in the gallery faster.
context.sendBroadcast(
Intent(
Intent.ACTION_MEDIA_SCANNER_SCAN_FILE,
outputFile.toUri()
)
)
}
private const val PICTURES_SUBDIRECTORY = "Amethyst"
}

View File

@@ -1,25 +1,19 @@
package com.vitorpamplona.amethyst.ui.actions
import androidx.compose.foundation.border
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
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.Checkbox
import androidx.compose.material.Colors
import androidx.compose.material.Divider
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
@@ -28,18 +22,12 @@ import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Cancel
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.DownloadDone
import androidx.compose.material.icons.filled.Groups
import androidx.compose.material.icons.filled.Public
import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.filled.SyncProblem
import androidx.compose.material.icons.filled.Upload
import androidx.compose.material.icons.outlined.BarChart
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
@@ -48,12 +36,10 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@@ -62,6 +48,7 @@ import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.RelaySetupInfo
import com.vitorpamplona.amethyst.service.relays.FeedType
import java.lang.Math.round
@@ -206,16 +193,16 @@ fun ServerConfigHeader() {
@Composable
fun ServerConfig(
item: NewRelayListViewModel.Relay,
onToggleDownload: (NewRelayListViewModel.Relay) -> Unit,
onToggleUpload: (NewRelayListViewModel.Relay) -> Unit,
item: RelaySetupInfo,
onToggleDownload: (RelaySetupInfo) -> Unit,
onToggleUpload: (RelaySetupInfo) -> Unit,
onToggleFollows: (NewRelayListViewModel.Relay) -> Unit,
onTogglePrivateDMs: (NewRelayListViewModel.Relay) -> Unit,
onTogglePublicChats: (NewRelayListViewModel.Relay) -> Unit,
onToggleGlobal: (NewRelayListViewModel.Relay) -> Unit,
onToggleFollows: (RelaySetupInfo) -> Unit,
onTogglePrivateDMs: (RelaySetupInfo) -> Unit,
onTogglePublicChats: (RelaySetupInfo) -> Unit,
onToggleGlobal: (RelaySetupInfo) -> Unit,
onDelete: (NewRelayListViewModel.Relay) -> Unit) {
onDelete: (RelaySetupInfo) -> Unit) {
Column(Modifier.fillMaxWidth()) {
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -376,7 +363,7 @@ fun ServerConfig(
}
@Composable
fun EditableServerConfig(relayToAdd: String, onNewRelay: (NewRelayListViewModel.Relay) -> Unit) {
fun EditableServerConfig(relayToAdd: String, onNewRelay: (RelaySetupInfo) -> Unit) {
var url by remember { mutableStateOf<String>(relayToAdd) }
var read by remember { mutableStateOf(true) }
var write by remember { mutableStateOf(true) }
@@ -422,9 +409,9 @@ fun EditableServerConfig(relayToAdd: String, onNewRelay: (NewRelayListViewModel.
Button(
onClick = {
if (url.isNotBlank() && url != "/") {
var addedWSS = if (!url.startsWith("wss://")) "wss://$url" else url
var addedWSS = if (!url.startsWith("wss://") && !url.startsWith("ws://")) "wss://$url" else url
if (url.endsWith("/")) addedWSS = addedWSS.dropLast(1)
onNewRelay(NewRelayListViewModel.Relay(addedWSS, read, write, feedTypes = FeedType.values().toSet()))
onNewRelay(RelaySetupInfo(addedWSS, read, write, feedTypes = FeedType.values().toSet()))
url = ""
write = true
read = true

View File

@@ -1,32 +1,20 @@
package com.vitorpamplona.amethyst.ui.actions
import android.content.Context
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.ViewModel
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.relays.Constants
import com.vitorpamplona.amethyst.model.RelaySetupInfo
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.RelayPool
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import nostr.postr.events.ContactListEvent
class NewRelayListViewModel: ViewModel() {
private lateinit var account: Account
data class Relay(
val url: String,
val read: Boolean,
val write: Boolean,
val errorCount: Int = 0,
val downloadCount: Int = 0,
val uploadCount: Int = 0,
val feedTypes: Set<FeedType>
)
private val _relays = MutableStateFlow<List<Relay>>(emptyList())
private val _relays = MutableStateFlow<List<RelaySetupInfo>>(emptyList())
val relays = _relays.asStateFlow()
fun load(account: Account, ctx: Context) {
@@ -56,7 +44,7 @@ class NewRelayListViewModel: ViewModel() {
val eventDownloadCounter = liveRelay?.eventDownloadCounter ?: 0
val eventUploadCounter = liveRelay?.eventUploadCounter ?: 0
Relay(it.key, it.value.read, it.value.write, errorCounter, eventDownloadCounter, eventUploadCounter, localInfoFeedTypes)
RelaySetupInfo(it.key, it.value.read, it.value.write, errorCounter, eventDownloadCounter, eventUploadCounter, localInfoFeedTypes)
}.sortedBy { it.downloadCount }.reversed()
else
account.localRelays.map {
@@ -66,12 +54,12 @@ class NewRelayListViewModel: ViewModel() {
val eventDownloadCounter = liveRelay?.eventDownloadCounter ?: 0
val eventUploadCounter = liveRelay?.eventUploadCounter ?: 0
Relay(it.url, it.read, it.write, errorCounter, eventDownloadCounter, eventUploadCounter, it.feedTypes)
RelaySetupInfo(it.url, it.read, it.write, errorCounter, eventDownloadCounter, eventUploadCounter, it.feedTypes)
}.sortedBy { it.downloadCount }.reversed()
}
}
fun addRelay(relay: Relay) {
fun addRelay(relay: RelaySetupInfo) {
if (relays.value.any { it.url == relay.url }) return
_relays.update {
@@ -79,46 +67,46 @@ class NewRelayListViewModel: ViewModel() {
}
}
fun deleteRelay(relay: Relay) {
fun deleteRelay(relay: RelaySetupInfo) {
_relays.update {
it.minus(relay)
}
}
fun toggleDownload(relay: Relay) {
fun toggleDownload(relay: RelaySetupInfo) {
_relays.update {
it.updated(relay, relay.copy(read = !relay.read))
}
}
fun toggleUpload(relay: Relay) {
fun toggleUpload(relay: RelaySetupInfo) {
_relays.update {
it.updated(relay, relay.copy(write = !relay.write))
}
}
fun toggleFollows(relay: Relay) {
fun toggleFollows(relay: RelaySetupInfo) {
val newTypes = togglePresenceInSet(relay.feedTypes, FeedType.FOLLOWS)
_relays.update {
it.updated(relay, relay.copy(feedTypes = newTypes))
}
}
fun toggleMessages(relay: Relay) {
fun toggleMessages(relay: RelaySetupInfo) {
val newTypes = togglePresenceInSet(relay.feedTypes, FeedType.PRIVATE_DMS)
_relays.update {
it.updated(relay, relay.copy(feedTypes = newTypes))
}
}
fun togglePublicChats(relay: Relay) {
fun togglePublicChats(relay: RelaySetupInfo) {
val newTypes = togglePresenceInSet(relay.feedTypes, FeedType.PUBLIC_CHATS)
_relays.update {
it.updated(relay, relay.copy(feedTypes = newTypes))
}
}
fun toggleGlobal(relay: Relay) {
fun toggleGlobal(relay: RelaySetupInfo) {
val newTypes = togglePresenceInSet(relay.feedTypes, FeedType.GLOBAL)
_relays.update {
it.updated(relay, relay.copy( feedTypes = newTypes ))

View File

@@ -205,6 +205,22 @@ fun NewUserMetadataView(onClose: () -> Unit, account: Account) {
singleLine = true
)
Spacer(modifier = Modifier.height(10.dp))
OutlinedTextField(
label = { Text(text = "LN URL (outdated)") },
modifier = Modifier.fillMaxWidth(),
value = postViewModel.lnURL.value,
onValueChange = { postViewModel.lnURL.value = it },
placeholder = {
Text(
text = "LNURL...",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
singleLine = true
)
}
}
}

View File

@@ -21,6 +21,7 @@ class NewUserMetadataViewModel: ViewModel() {
val website = mutableStateOf("")
val nip05 = mutableStateOf("")
val lnAddress = mutableStateOf("")
val lnURL = mutableStateOf("")
fun load(account: Account) {
this.account = account
@@ -34,6 +35,7 @@ class NewUserMetadataViewModel: ViewModel() {
website.value = it.info.website ?: ""
nip05.value = it.info.nip05 ?: ""
lnAddress.value = it.info.lud16 ?: ""
lnURL.value = it.info.lud06 ?: ""
}
}
@@ -57,6 +59,7 @@ class NewUserMetadataViewModel: ViewModel() {
currentJson.put("about", about.value)
currentJson.put("nip05", nip05.value)
currentJson.put("lud16", lnAddress.value)
currentJson.put("lud06", lnURL.value)
val writer = StringWriter()
ObjectMapper().writeValue(writer, currentJson)
@@ -75,5 +78,6 @@ class NewUserMetadataViewModel: ViewModel() {
website.value = ""
nip05.value = ""
lnAddress.value = ""
lnURL.value = ""
}
}

View File

@@ -0,0 +1,83 @@
package com.vitorpamplona.amethyst.ui.actions
import android.Manifest
import android.os.Build
import android.widget.Toast
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import kotlinx.coroutines.launch
/**
* A button to save the remote image to the gallery.
* May require a storage permission.
*
* @param url URL of the image
*/
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun SaveToGallery(url: String) {
val localContext = LocalContext.current
val scope = rememberCoroutineScope()
fun saveImage() {
ImageSaver.saveImage(
context = localContext,
url = url,
onSuccess = {
scope.launch {
Toast.makeText(
localContext,
"Image saved to the gallery",
Toast.LENGTH_SHORT
)
.show()
}
},
onError = {
scope.launch {
Toast.makeText(
localContext,
"Failed to save the image",
Toast.LENGTH_SHORT
)
.show()
}
}
)
}
val writeStoragePermissionState = rememberPermissionState(
Manifest.permission.WRITE_EXTERNAL_STORAGE
) { isGranted ->
if (isGranted) {
saveImage()
}
}
Button(
onClick = {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q || writeStoragePermissionState.status.isGranted) {
saveImage()
} else {
writeStoragePermissionState.launchPermissionRequest()
}
},
shape = RoundedCornerShape(20.dp),
colors = ButtonDefaults
.buttonColors(
backgroundColor = Color.Gray
)
) {
Text(text = "Save", color = Color.White)
}
}

View File

@@ -1,6 +1,7 @@
package com.vitorpamplona.amethyst.ui.navigation
import android.net.Uri
import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Box
@@ -24,9 +25,14 @@ import com.google.accompanist.permissions.rememberPermissionState
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun UploadFromGallery(onImageChosen: (Uri) -> Unit) {
val cameraPermissionState = rememberPermissionState(
android.Manifest.permission.READ_MEDIA_IMAGES
)
val cameraPermissionState =
rememberPermissionState(
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
android.Manifest.permission.READ_MEDIA_IMAGES
} else {
android.Manifest.permission.READ_EXTERNAL_STORAGE
}
)
if (cameraPermissionState.status.isGranted) {
var showGallerySelect by remember { mutableStateOf(false) }

View File

@@ -68,7 +68,7 @@ fun InvoiceRequest(lud16: String, onClose: () -> Unit ) {
)
Text(
text = "Lightning Invoice",
text = "Lightning Tips",
fontSize = 20.sp,
fontWeight = FontWeight.W500,
modifier = Modifier.padding(start = 10.dp)
@@ -101,7 +101,14 @@ fun InvoiceRequest(lud16: String, onClose: () -> Unit ) {
label = { Text(text = "Amount in Sats") },
modifier = Modifier.fillMaxWidth(),
value = amount.toString(),
onValueChange = { amount = it.toLong() },
onValueChange = {
runCatching {
if (it.isEmpty())
amount = 0
else
amount = it.toLong()
}
},
placeholder = {
Text(
text = "1000",
@@ -130,7 +137,7 @@ fun InvoiceRequest(lud16: String, onClose: () -> Unit ) {
backgroundColor = MaterialTheme.colors.primary
)
) {
Text(text = "Send", color = Color.White, fontSize = 20.sp)
Text(text = "Send Sats", color = Color.White, fontSize = 20.sp)
}
}
}

View File

@@ -2,35 +2,26 @@ package com.vitorpamplona.amethyst.ui.components
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import coil.compose.AsyncImage
import com.vitorpamplona.amethyst.ui.actions.CloseButton
import nostr.postr.toNpub
import com.vitorpamplona.amethyst.ui.actions.SaveToGallery
@Composable
@OptIn(ExperimentalComposeUiApi::class, ExperimentalFoundationApi::class)
@@ -75,6 +66,8 @@ fun ZoomableImageView(word: String) {
CloseButton(onCancel = {
dialogOpen = false
})
SaveToGallery(url = word)
}
ZoomableAsyncImage(word)

View File

@@ -1,7 +1,9 @@
package com.vitorpamplona.amethyst.ui.navigation
import android.util.Log
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -36,6 +38,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavHostController
@@ -72,6 +75,7 @@ fun AppTopBar(navController: NavHostController, scaffoldState: ScaffoldState, ac
}
}
@Composable
fun MainTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) {
val accountState by accountViewModel.accountLiveData.observeAsState()
@@ -104,12 +108,13 @@ fun MainTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel)
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Box() {
Box(Modifier) {
Column(
modifier = Modifier
.fillMaxWidth()
.fillMaxWidth().fillMaxHeight()
.padding(start = 0.dp, end = 20.dp),
horizontalAlignment = Alignment.CenterHorizontally
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
IconButton(
onClick = {

View File

@@ -62,46 +62,55 @@ fun ShowQRDialog(user: User, onScan: (String) -> Unit, onClose: () -> Unit) {
}
Column(
modifier = Modifier.fillMaxSize().padding(10.dp),
verticalArrangement = Arrangement.Center
modifier = Modifier.fillMaxSize().padding(horizontal = 10.dp),
verticalArrangement = Arrangement.SpaceBetween
) {
if (presenting) {
Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) {
AsyncImage(
model = user.profilePicture() ?: "https://robohash.org/ohno.png",
contentDescription = "Profile Image",
placeholder = rememberAsyncImagePainter("https://robohash.org/${user.pubkeyHex}.png"),
modifier = Modifier
.width(120.dp)
.height(120.dp)
.clip(shape = CircleShape)
.border(3.dp, MaterialTheme.colors.background, CircleShape)
.background(MaterialTheme.colors.background)
)
}
Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) {
Text(
user.bestDisplayName() ?: "",
modifier = Modifier.padding(top = 7.dp),
fontWeight = FontWeight.Bold,
fontSize = 18.sp
)
}
Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) {
Text(" @${user.bestUsername()}", color = Color.LightGray)
}
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth().padding(30.dp)
modifier = Modifier.fillMaxWidth().padding(horizontal = 30.dp, vertical = 10.dp)
) {
QrCodeDrawer("nostr:${user.pubkey.toNpub()}")
}
Column(modifier = Modifier.fillMaxWidth()) {
Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) {
AsyncImage(
model = user.profilePicture() ?: "https://robohash.org/ohno.png",
contentDescription = "Profile Image",
placeholder = rememberAsyncImagePainter("https://robohash.org/${user.pubkeyHex}.png"),
modifier = Modifier
.width(100.dp)
.height(100.dp)
.clip(shape = CircleShape)
.border(3.dp, MaterialTheme.colors.background, CircleShape)
.background(MaterialTheme.colors.background)
)
}
Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) {
Text(
user.bestDisplayName() ?: "",
modifier = Modifier.padding(top = 7.dp),
fontWeight = FontWeight.Bold,
fontSize = 18.sp
)
}
Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) {
Text(" @${user.bestUsername()}", color = Color.LightGray)
}
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth().padding(horizontal = 35.dp, vertical = 10.dp)
) {
QrCodeDrawer("nostr:${user.pubkey.toNpub()}")
}
}
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth().padding(30.dp)
modifier = Modifier.fillMaxWidth().padding(horizontal = 30.dp, vertical = 10.dp)
) {
Button(
@@ -121,6 +130,15 @@ fun ShowQRDialog(user: User, onScan: (String) -> Unit, onClose: () -> Unit) {
} else {
Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) {
Text(
"Point to the QR Code",
modifier = Modifier.padding(top = 7.dp),
fontWeight = FontWeight.Bold,
fontSize = 25.sp
)
}
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth().padding(30.dp)
@@ -128,18 +146,24 @@ fun ShowQRDialog(user: User, onScan: (String) -> Unit, onClose: () -> Unit) {
QrCodeScanner(onScan)
}
Button(
onClick = { presenting = true },
shape = RoundedCornerShape(35.dp),
modifier = Modifier
.fillMaxWidth().padding(30.dp)
.height(50.dp),
colors = ButtonDefaults
.buttonColors(
backgroundColor = MaterialTheme.colors.primary
)
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth().padding(horizontal = 30.dp, vertical = 10.dp)
) {
Text(text = "Show QR")
Button(
onClick = { presenting = true },
shape = RoundedCornerShape(35.dp),
modifier = Modifier
.fillMaxWidth()
.height(50.dp),
colors = ButtonDefaults
.buttonColors(
backgroundColor = MaterialTheme.colors.primary
)
) {
Text(text = "Show QR")
}
}
}

View File

@@ -190,12 +190,12 @@ fun ChannelHeader(baseChannel: Channel, account: Account, accountStateViewModel:
channel?.let {
if (channel.creator == account.userProfile()) {
EditButton(account, it)
}
if (account.followingChannels.contains(channel.idHex)) {
LeaveButton(account, channel, navController)
} else {
if (account.followingChannels.contains(channel.idHex)) {
LeaveButton(account, channel, navController)
} else {
JoinButton(account, channel, navController)
}
JoinButton(account, channel, navController)
}
}
}

View File

@@ -313,7 +313,6 @@ private fun DrawAdditionalInfo(baseUser: User) {
val user = userState?.user ?: return
val uri = LocalUriHandler.current
val context = LocalContext.current
Text(
user.bestDisplayName() ?: "",
@@ -339,7 +338,7 @@ private fun DrawAdditionalInfo(baseUser: User) {
ClickableText(
text = AnnotatedString(website.removePrefix("https://")),
onClick = { user.info.website?.let { uri.openUri(it) } },
onClick = { user.info.website?.let { runCatching { uri.openUri(it) } } },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary),
modifier = Modifier.padding(top = 1.dp, bottom = 1.dp, start = 5.dp)
)