Compare commits

...

35 Commits
v0.9 ... v0.9.4

Author SHA1 Message Date
Vitor Pamplona
b6c1273ecd v0.9.4 2023-01-21 00:55:25 -03:00
Vitor Pamplona
07f9c6e8c3 keeping other classes to make sure deserialization works. 2023-01-21 00:54:39 -03:00
Vitor Pamplona
ea53a15105 Adding a Basic Privacy Policy 2023-01-20 23:56:54 -03:00
Vitor Pamplona
716a150fc3 Changing my mind... keeping the dot in time ago 2023-01-20 23:33:43 -03:00
Vitor Pamplona
df3ae43ce7 Adjusting the message screen to correctly display the time aligned to the right. 2023-01-20 23:31:44 -03:00
Vitor Pamplona
09bbb59c61 v0.9.3 2023-01-20 23:05:56 -03:00
Vitor Pamplona
0f731117d4 Seems to solve constant crashing when reconnecting after a failure related to this issue: https://github.com/square/okhttp/issues/7381 2023-01-20 23:01:53 -03:00
Vitor Pamplona
115c1ba082 Edit Profile 2023-01-20 23:00:30 -03:00
Vitor Pamplona
135b24df6c Fixing NPE when socket disconnects after the initial check if == null 2023-01-20 18:57:50 -03:00
Vitor Pamplona
207f03bb06 Merge pull request #6 from KotlinGeekDev/fix-navigation-issues
Fix navigation.
2023-01-20 16:48:25 -05:00
KotlinGeekDev
e78c7aba85 Fix navigation. 2023-01-20 22:31:19 +01:00
Vitor Pamplona
41b82ec749 Merge pull request #3 from KotlinGeekDev/reduce-apk-size
Make changes to reduce the release APK/app bundle size.
2023-01-20 14:44:58 -05:00
KotlinGeekDev
b915956f57 Make changes to reduce the release APK/app bundle size. 2023-01-20 20:26:46 +01:00
Vitor Pamplona
144b0a7512 v0.9.2:
- Fixes bug when tagging new users on reply
- Fixes bug on likes not being shown.
- Fixes alignment of the time in the home feed.
2023-01-19 21:48:15 -05:00
Vitor Pamplona
359eb0b000 Downloading just the last metadata for users being displayed in the screen. 2023-01-19 21:40:24 -05:00
Vitor Pamplona
e47476129f BugFix for the position of time in the feed. 2023-01-19 21:39:43 -05:00
Vitor Pamplona
0017845de2 Fixing the bug that wasn't showing likes 2023-01-19 21:35:57 -05:00
Vitor Pamplona
ab0bab0c16 Solving User tagging bug 2023-01-19 21:35:36 -05:00
Vitor Pamplona
b9b1da1f04 v0.9.1 2023-01-19 18:26:12 -05:00
Vitor Pamplona
0e22a25d3d Removing double call refresh 2023-01-19 18:14:56 -05:00
Vitor Pamplona
8b61dc09d0 Showing likes for Public Chat events. 2023-01-19 18:14:42 -05:00
Vitor Pamplona
e1fa46290e Correctly shows Chat notifications in the Notifications Tab. 2023-01-19 17:58:35 -05:00
Vitor Pamplona
ec5f510264 Shows replies in chat 2023-01-19 17:58:01 -05:00
Vitor Pamplona
2d46b17493 Block like breaks in the User Information block of the layout. 2023-01-19 17:57:43 -05:00
Vitor Pamplona
99f478d891 Fixing large usernames breaking the layout 2023-01-19 17:56:44 -05:00
Vitor Pamplona
451137e8fa handling co-routines with viewModel Scope 2023-01-19 17:56:22 -05:00
Vitor Pamplona
fc88c2867d Adding replying information for notes in a channel 2023-01-19 17:55:57 -05:00
Vitor Pamplona
98704bc43d remove transformations 2023-01-19 17:55:37 -05:00
Vitor Pamplona
c5c8ffc70f Avoid using trasnformations. They won't work in this application (base object never changes) 2023-01-19 17:55:25 -05:00
Vitor Pamplona
94a228d78a Simplifying the name of the "Add Image from Gallery" button 2023-01-19 17:54:30 -05:00
Vitor Pamplona
625bbaf35c Marking relays with failure as not connected 2023-01-19 17:54:07 -05:00
Vitor Pamplona
dd35e01f8a Fixing null pointer exceptions 2023-01-19 17:53:51 -05:00
Vitor Pamplona
c1d46dcc2f Using short-term co-routines 2023-01-19 17:52:32 -05:00
Vitor Pamplona
85c66279b2 Removing unnecessary code 2023-01-19 17:51:35 -05:00
Vitor Pamplona
a18c5b975f Solving some of the Out of Memory errors. 2023-01-19 17:50:29 -05:00
40 changed files with 618 additions and 152 deletions

8
PRIVACY.md Normal file
View File

@@ -0,0 +1,8 @@
# Amethyst Privacy Policy
Effective as of Jan 20, 2023.
The Amethyst app for Android does not collect or process any personal information from its users. The app is used to connect to third-party Nostr servers (also called Relays) that may or may not collect personal information and are not covered by this privacy policy. Each third-party relay server comes equipped with its own privacy policy that can be viewed through the app or through that server's website. The developers of this open source project or maintainers of the distribution channels (app stores) do not have access to the data located in the user's phone before it's shared with third-party Nostr servers. Accounts are fully maintained by the user. We do not have control over them.
We reserve the right to modify this Privacy Policy at any time. Any modifications to this Privacy Policy will be effective upon our posting the new terms and/or upon implementation of the new changes on the Service (or as otherwise indicated at the time of posting). In all cases, your continued use of the app after the posting of any modified Privacy Policy indicates your acceptance of the terms of the modified Privacy Policy.

View File

@@ -11,8 +11,8 @@ android {
applicationId "com.vitorpamplona.amethyst"
minSdk 26
targetSdk 33
versionCode 11
versionName "0.9"
versionCode 15
versionName "0.9.4"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@@ -22,7 +22,7 @@ android {
buildTypes {
release {
minifyEnabled false
minifyEnabled true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}

View File

@@ -18,4 +18,10 @@
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
#-renamesourcefileattribute SourceFile
# For the Secp256k1 library
-keep class fr.acinq.secp256k1.jni.** { *; }
# For the NostrPostr library
-keep class nostr.postr.** { *; }
-keep class com.vitorpamplona.amethyst.model.** { *; }
-keep class com.vitorpamplona.amethyst.service.** { *; }

View File

@@ -14,6 +14,7 @@
android:enableOnBackInvokedCallback="true"
android:supportsRtl="true"
android:theme="@style/Theme.Amethyst"
android:largeHeap="true"
tools:targetApi="31">
<activity
android:name=".ui.MainActivity"

View File

@@ -9,11 +9,13 @@ import com.vitorpamplona.amethyst.service.model.ReactionEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import java.util.Date
import nostr.postr.Contact
import nostr.postr.Persona
import nostr.postr.Utils
import nostr.postr.events.ContactListEvent
import nostr.postr.events.Event
import nostr.postr.events.MetadataEvent
import nostr.postr.events.PrivateDmEvent
import nostr.postr.events.TextNoteEvent
import nostr.postr.toHex
@@ -38,6 +40,23 @@ class Account(val loggedIn: Persona, val followingChannels: MutableSet<String> =
return loggedIn.privKey != null
}
fun sendNewUserMetadata(toString: String) {
if (!isWriteable()) return
loggedIn.privKey?.let {
val createdAt = Date().time / 1000
val content = toString
val pubKey = Utils.pubkeyCreate(it)
val tags = listOf<List<String>>()
val id = Event.generateId(pubKey, createdAt, MetadataEvent.kind, tags, content)
val sig = Utils.sign(id, it)
val event = MetadataEvent(id, pubKey, createdAt, tags, content, sig)
Client.send(event)
LocalCache.consume(event)
}
}
fun reactTo(note: Note) {
if (!isWriteable()) return
@@ -72,7 +91,7 @@ class Account(val loggedIn: Persona, val followingChannels: MutableSet<String> =
fun follow(user: User) {
if (!isWriteable()) return
val lastestContactList = userProfile().lastestContactList
val lastestContactList = userProfile().latestContactList
val event = if (lastestContactList != null) {
ContactListEvent.create(lastestContactList.follows.plus(Contact(user.pubkeyHex, null)), lastestContactList.relayUse, loggedIn.privKey!!)
} else {
@@ -87,7 +106,7 @@ class Account(val loggedIn: Persona, val followingChannels: MutableSet<String> =
fun unfollow(user: User) {
if (!isWriteable()) return
val lastestContactList = userProfile().lastestContactList
val lastestContactList = userProfile().latestContactList
if (lastestContactList != null) {
val event = ContactListEvent.create(lastestContactList.follows.filter { it.pubKeyHex != user.pubkeyHex }, lastestContactList.relayUse, loggedIn.privKey!!)
Client.send(event)
@@ -95,36 +114,20 @@ class Account(val loggedIn: Persona, val followingChannels: MutableSet<String> =
}
}
fun sendPost(message: String, originalNote: Note?, modifiedMentions: List<User>?) {
fun sendPost(message: String, replyTo: List<Note>?, mentions: List<User>?) {
if (!isWriteable()) return
val replyToEvent = originalNote?.event
if (replyToEvent is TextNoteEvent) {
val modifiedMentionsHex = modifiedMentions?.map { it.pubkeyHex }?.toSet() ?: emptySet()
val repliesToHex = replyTo?.map { it.idHex }
val mentionsHex = mentions?.map { it.pubkeyHex }
val repliesTo = replyToEvent.replyTos.plus(replyToEvent.id.toHex())
val mentions = replyToEvent.mentions.plus(replyToEvent.pubKey.toHex()).plus(modifiedMentionsHex).filter {
it in modifiedMentionsHex
}
val signedEvent = TextNoteEvent.create(
msg = message,
replyTos = repliesTo,
mentions = mentions,
privateKey = loggedIn.privKey!!
)
Client.send(signedEvent)
LocalCache.consume(signedEvent)
} else {
val signedEvent = TextNoteEvent.create(
msg = message,
replyTos = null,
mentions = modifiedMentions?.map { it.pubkeyHex } ?: emptyList(),
privateKey = loggedIn.privKey!!
)
Client.send(signedEvent)
LocalCache.consume(signedEvent)
}
val signedEvent = TextNoteEvent.create(
msg = message,
replyTos = repliesToHex,
mentions = mentionsHex,
privateKey = loggedIn.privKey!!
)
Client.send(signedEvent)
LocalCache.consume(signedEvent)
}
fun sendChannelMeesage(message: String, toChannel: String, replyingTo: Note? = null) {
@@ -237,6 +240,8 @@ class Account(val loggedIn: Persona, val followingChannels: MutableSet<String> =
private fun refreshObservers() {
live.refresh()
}
}
class AccountLiveData(private val account: Account): LiveData<AccountState>(AccountState(account)) {

View File

@@ -80,6 +80,7 @@ object LocalCache {
}
oldUser.updateUserInfo(newUser, event.createdAt)
oldUser.latestMetadata = event
} else {
//Log.d("MT","Relay sent a previous Metadata Event ${oldUser.toBestDisplayName()} ${formattedDateTime(event.createdAt)} > ${formattedDateTime(oldUser.updatedAt)}")
}
@@ -147,7 +148,7 @@ object LocalCache {
event.createdAt
)
user.lastestContactList = event
user.latestContactList = event
}
refreshObservers()
@@ -245,7 +246,7 @@ object LocalCache {
}
fun consume(event: ChannelCreateEvent) {
Log.d("MT", "New Event ${event.content} ${event.id.toHex()}")
//Log.d("MT", "New Event ${event.content} ${event.id.toHex()}")
// new event
val oldChannel = getOrCreateChannel(event.id.toHex())
val author = getOrCreateUser(event.pubKey)

View File

@@ -100,13 +100,13 @@ class Note(val idHex: String) {
val live: NoteLiveData = NoteLiveData(this)
// Refreshes observers in batches.
val scope = CoroutineScope(Job() + Dispatchers.Main)
var handlerWaiting = false
@Synchronized
fun invalidateData() {
if (handlerWaiting) return
handlerWaiting = true
val scope = CoroutineScope(Job() + Dispatchers.Main)
scope.launch {
delay(100)
live.refresh()

View File

@@ -1,9 +1,6 @@
package com.vitorpamplona.amethyst.model
import android.os.Handler
import android.os.Looper
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import java.util.Collections
@@ -14,6 +11,7 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import nostr.postr.events.ContactListEvent
import nostr.postr.events.MetadataEvent
class User(val pubkey: ByteArray) {
val pubkeyHex = pubkey.toHexKey()
@@ -24,7 +22,8 @@ class User(val pubkey: ByteArray) {
var updatedMetadataAt: Long = 0;
var updatedFollowsAt: Long = 0;
var lastestContactList: ContactListEvent? = null
var latestContactList: ContactListEvent? = null
var latestMetadata: MetadataEvent? = null
val notes = Collections.synchronizedSet(mutableSetOf<Note>())
val follows = Collections.synchronizedSet(mutableSetOf<User>())
@@ -113,13 +112,13 @@ class User(val pubkey: ByteArray) {
val live: UserLiveData = UserLiveData(this)
// Refreshes observers in batches.
val scope = CoroutineScope(Job() + Dispatchers.Main)
var handlerWaiting = false
@Synchronized
fun invalidateData() {
if (handlerWaiting) return
handlerWaiting = true
val scope = CoroutineScope(Job() + Dispatchers.Main)
scope.launch {
delay(100)
live.refresh()

View File

@@ -59,7 +59,7 @@ object NostrAccountDataSource: NostrDataSource<Note>("AccountData") {
return LocalCache.notes.values
.filter { (it.event is TextNoteEvent || it.event is RepostEvent) && it.author?.pubkeyHex in allowSet }
.sortedBy { it.event!!.createdAt }
.sortedBy { it.event?.createdAt }
.reversed()
}

View File

@@ -35,7 +35,7 @@ object NostrChatRoomDataSource: NostrDataSource<Note>("ChatroomFeed") {
override fun feed(): List<Note> {
val messages = account.userProfile().messages[withUser]
return messages?.sortedBy { it.event!!.createdAt } ?: emptyList()
return messages?.sortedBy { it.event?.createdAt } ?: emptyList()
}
override fun updateChannelFilters() {

View File

@@ -148,21 +148,28 @@ abstract class NostrDataSource<T>(val debugName: String) {
channelIds.remove(channel.id)
}
val scope = CoroutineScope(Job() + Dispatchers.IO)
var handlerWaiting = false
@Synchronized
fun invalidateFilters() {
if (handlerWaiting) return
handlerWaiting = true
val scope = CoroutineScope(Job() + Dispatchers.IO)
scope.launch {
delay(200)
resetFilters()
resetFiltersSuspend()
handlerWaiting = false
}
}
fun resetFilters() {
val scope = CoroutineScope(Job() + Dispatchers.IO)
scope.launch {
resetFiltersSuspend()
}
}
fun resetFiltersSuspend() {
// saves the channels that are currently active
val activeChannels = channels.filter { it.filter != null }
// saves the current content to only update if it changes

View File

@@ -17,7 +17,7 @@ object NostrGlobalDataSource: NostrDataSource<Note>("GlobalFeed") {
.filter {
it.event is TextNoteEvent && (it.event as TextNoteEvent).replyTos.isEmpty()
}
.sortedBy { it.event!!.createdAt }
.sortedBy { it.event?.createdAt }
.reversed()
override fun updateChannelFilters() {

View File

@@ -76,7 +76,7 @@ object NostrHomeDataSource: NostrDataSource<Note>("HomeFeed") {
return LocalCache.notes.values
.filter { (it.event is TextNoteEvent || it.event is RepostEvent) && it.author?.pubkeyHex in allowSet }
.sortedBy { it.event!!.createdAt }
.sortedBy { it.event?.createdAt }
.reversed()
}

View File

@@ -20,7 +20,7 @@ object NostrNotificationDataSource: NostrDataSource<Note>("NotificationFeed") {
set.filter { it.event != null }
}
return filtered.sortedBy { it.event!!.createdAt }.reversed()
return filtered.sortedBy { it.event?.createdAt }.reversed()
}
override fun updateChannelFilters() {

View File

@@ -12,7 +12,7 @@ object NostrSingleEventDataSource: NostrDataSource<Note>("SingleEventFeed") {
private var eventsToWatch = setOf<String>()
private fun createRepliesAndReactionsFilter(): JsonFilter? {
val reactionsToWatch = eventsToWatch.map { it.substring(0, 8) }
val reactionsToWatch = eventsToWatch.map { it }
if (reactionsToWatch.isEmpty()) {
return null
@@ -38,7 +38,7 @@ object NostrSingleEventDataSource: NostrDataSource<Note>("SingleEventFeed") {
val interestedEvents =
(directEventsToLoad + threadingEventsToLoad)
.map { it.idHex.substring(0, 8) }
.map { it.idHex }
if (interestedEvents.isEmpty()) {
return null

View File

@@ -16,7 +16,7 @@ object NostrSingleUserDataSource: NostrDataSource<Note>("SingleUserFeed") {
JsonFilter(
kinds = listOf(MetadataEvent.kind),
authors = listOf(it.substring(0, 8)),
limit = 10
limit = 1
)
}
}

View File

@@ -12,7 +12,7 @@ object NostrThreadDataSource: NostrDataSource<Note>("SingleThreadFeed") {
val eventsToWatch = Collections.synchronizedList(mutableListOf<String>())
fun createRepliesAndReactionsFilter(): JsonFilter? {
val reactionsToWatch = eventsToWatch.map { it.substring(0, 8) }
val reactionsToWatch = eventsToWatch.map { it }
if (reactionsToWatch.isEmpty()) {
return null

View File

@@ -30,6 +30,7 @@ class Relay(
}
fun requestAndWatch() {
println("Connecting with ${url}")
val request = Request.Builder().url(url).build()
val listener = object : WebSocketListener() {
@@ -87,6 +88,10 @@ class Relay(
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
socket?.close(1000, "Normal close")
// Failures disconnect the relay.
socket = null
//println("Relay onFailure ${url}, ${response?.message}")
t.printStackTrace()
listeners.forEach {
it.onError(this@Relay, "", Error("WebSocket Failure. Response: ${response}. Exception: ${t.message}", t))
@@ -99,6 +104,7 @@ class Relay(
fun disconnect() {
//httpClient.dispatcher.executorService.shutdown()
socket?.close(1000, "Normal close")
socket = null
}
fun sendFilter(requestId: String) {
@@ -109,7 +115,7 @@ class Relay(
if (filters.isNotEmpty()) {
val request = """["REQ","$requestId",${filters.joinToString(",") { it.toJson() }}]"""
//println("FILTERSSENT " + """["REQ","$requestId",${filters.joinToString(",") { it.toJson() }}]""")
socket!!.send(request)
socket?.send(request)
}
}
}

View File

@@ -3,6 +3,10 @@ package com.vitorpamplona.amethyst.service.relays
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.service.Constants
import java.util.Collections
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import nostr.postr.events.Event
/**
@@ -108,7 +112,10 @@ object RelayPool: Relay.Listener {
val live: RelayPoolLiveData = RelayPoolLiveData(this)
private fun refreshObservers() {
live.refresh()
val scope = CoroutineScope(Job() + Dispatchers.Main)
scope.launch {
live.refresh()
}
}
}

View File

@@ -38,7 +38,13 @@ class NewPostViewModel: ViewModel() {
replyingTo?.let { replyNote ->
this.replyTos = (replyNote.replyTo ?: mutableListOf()).plus(replyNote).toMutableList()
replyNote.author?.let { replyUser ->
this.mentions = (replyNote.mentions ?: emptyList()).plus(replyUser)
val currentMentions = replyNote.mentions ?: emptyList()
if (currentMentions.contains(replyUser)) {
this.mentions = currentMentions
} else {
this.mentions = currentMentions.plus(replyUser)
}
}
}
this.account = account
@@ -89,7 +95,7 @@ class NewPostViewModel: ViewModel() {
}.joinToString(" ")
}.joinToString("\n")
account?.sendPost(newMessage, originalNote, mentions)
account?.sendPost(newMessage, replyTos, mentions)
message = TextFieldValue("")
urlPreview = null
}

View File

@@ -0,0 +1,211 @@
package com.vitorpamplona.amethyst.ui.actions
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun NewUserMetadataView(onClose: () -> Unit, account: Account) {
val postViewModel: NewUserMetadataViewModel = viewModel()
LaunchedEffect(Unit) {
postViewModel.load(account)
}
Dialog(
onDismissRequest = { onClose() },
properties = DialogProperties(
usePlatformDefaultWidth = false,
dismissOnClickOutside = false
)
) {
Surface(
) {
Column(
modifier = Modifier.padding(10.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
CloseButton(onCancel = {
postViewModel.clear()
onClose()
})
PostButton(
onPost = {
postViewModel.create()
onClose()
},
postViewModel.userName.value.isNotBlank()
)
}
Spacer(modifier = Modifier.height(10.dp))
Row(modifier = Modifier.fillMaxWidth(1f), verticalAlignment = Alignment.CenterVertically) {
OutlinedTextField(
label = { Text(text = "Display Name") },
modifier = Modifier.weight(1f),
value = postViewModel.displayName.value,
onValueChange = { postViewModel.displayName.value = it },
placeholder = {
Text(
text = "My display name",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
keyboardOptions = KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences
),
singleLine = true
)
Text("@", Modifier.padding(5.dp))
OutlinedTextField(
label = { Text(text = "Username") },
modifier = Modifier.weight(1f),
value = postViewModel.userName.value,
onValueChange = { postViewModel.userName.value = it },
placeholder = {
Text(
text = "My username",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
singleLine = true
)
}
Spacer(modifier = Modifier.height(10.dp))
OutlinedTextField(
label = { Text(text = "About me") },
modifier = Modifier
.fillMaxWidth()
.height(100.dp),
value = postViewModel.about.value,
onValueChange = { postViewModel.about.value = it },
placeholder = {
Text(
text = "About me",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
keyboardOptions = KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences
),
maxLines = 10
)
Spacer(modifier = Modifier.height(10.dp))
OutlinedTextField(
label = { Text(text = "Avatar URL") },
modifier = Modifier.fillMaxWidth(),
value = postViewModel.picture.value,
onValueChange = { postViewModel.picture.value = it },
placeholder = {
Text(
text = "https://mywebsite.com/me.jpg",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
singleLine = true
)
Spacer(modifier = Modifier.height(10.dp))
OutlinedTextField(
label = { Text(text = "Banner URL") },
modifier = Modifier.fillMaxWidth(),
value = postViewModel.banner.value,
onValueChange = { postViewModel.banner.value = it },
placeholder = {
Text(
text = "https://mywebsite.com/mybanner.jpg",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
singleLine = true
)
Spacer(modifier = Modifier.height(10.dp))
OutlinedTextField(
label = { Text(text = "Website URL") },
modifier = Modifier.fillMaxWidth(),
value = postViewModel.website.value,
onValueChange = { postViewModel.website.value = it },
placeholder = {
Text(
text = "https://mywebsite.com",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
singleLine = true
)
Spacer(modifier = Modifier.height(10.dp))
OutlinedTextField(
label = { Text(text = "NIP-05") },
modifier = Modifier.fillMaxWidth(),
value = postViewModel.nip05.value,
onValueChange = { postViewModel.nip05.value = it },
placeholder = {
Text(
text = "_@mywebsite.com",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
singleLine = true
)
Spacer(modifier = Modifier.height(10.dp))
OutlinedTextField(
label = { Text(text = "LN Address") },
modifier = Modifier.fillMaxWidth(),
value = postViewModel.lnAddress.value,
onValueChange = { postViewModel.lnAddress.value = it },
placeholder = {
Text(
text = "me@mylightiningnode.com",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
singleLine = true
)
}
}
}
}

View File

@@ -0,0 +1,79 @@
package com.vitorpamplona.amethyst.ui.actions
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.node.ObjectNode
import com.vitorpamplona.amethyst.model.Account
import java.io.ByteArrayInputStream
import java.io.StringWriter
class NewUserMetadataViewModel: ViewModel() {
private lateinit var account: Account
val userName = mutableStateOf("")
val displayName = mutableStateOf("")
val about = mutableStateOf("")
val picture = mutableStateOf("")
val banner = mutableStateOf("")
val website = mutableStateOf("")
val nip05 = mutableStateOf("")
val lnAddress = mutableStateOf("")
fun load(account: Account) {
this.account = account
account.userProfile().let {
userName.value = it.bestUsername() ?: ""
displayName.value = it.bestDisplayName() ?: ""
about.value = it.info.about ?: ""
picture.value = it.info.picture ?: ""
banner.value = it.info.banner ?: ""
website.value = it.info.website ?: ""
nip05.value = it.info.nip05 ?: ""
lnAddress.value = it.info.lud16 ?: ""
}
}
fun create() {
// Tries to not delete any existing attribute that we do not work with.
val latest = account.userProfile().latestMetadata
val currentJson = if (latest != null) {
ObjectMapper().readTree(
ByteArrayInputStream(latest.content.toByteArray(Charsets.UTF_8))
) as ObjectNode
} else {
ObjectMapper().createObjectNode()
}
currentJson.put("name", userName.value)
currentJson.put("username", userName.value)
currentJson.put("display_name", displayName.value)
currentJson.put("displayName", displayName.value)
currentJson.put("picture", picture.value)
currentJson.put("banner", banner.value)
currentJson.put("website", website.value)
currentJson.put("about", about.value)
currentJson.put("nip05", nip05.value)
currentJson.put("lud06", lnAddress.value)
val writer = StringWriter()
ObjectMapper().writeValue(writer, currentJson)
account.sendNewUserMetadata(writer.buffer.toString())
clear()
}
fun clear() {
userName.value = ""
displayName.value = ""
about.value = ""
picture.value = ""
banner.value = ""
website.value = ""
nip05.value = ""
lnAddress.value = ""
}
}

View File

@@ -48,14 +48,14 @@ fun UploadFromGallery(onImageChosen: (Uri) -> Unit) {
showGallerySelect = true
}
) {
Text("Add Image from Gallery")
Text("Upload Image")
}
}
}
} else {
Column {
Button(onClick = { cameraPermissionState.launchPermissionRequest() }) {
Text("Add Image from Gallery")
Text("Upload Image")
}
}
}

View File

@@ -47,10 +47,24 @@ fun AppBottomBar(navController: NavHostController) {
selected = currentRoute == item.route,
onClick = {
if (currentRoute != item.route) {
navController.navigate(item.route)
navController.navigate(item.route){
navController.graph.startDestinationRoute?.let { start ->
popUpTo(start)
restoreState = true
}
launchSingleTop = true
restoreState = true
}
} else {
// TODO: Make it scrool to the top
navController.navigate(item.route)
navController.navigate(item.route){
navController.graph.startDestinationRoute?.let { start ->
popUpTo(start)
restoreState = true
}
launchSingleTop = true
restoreState = true
}
}
}
)

View File

@@ -45,6 +45,7 @@ import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource
import com.vitorpamplona.amethyst.service.NostrUserProfileFollowersDataSource
import com.vitorpamplona.amethyst.service.NostrUserProfileFollowsDataSource
import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.service.relays.RelayPool
import com.vitorpamplona.amethyst.ui.screen.RelayPoolViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.coroutines.launch
@@ -59,7 +60,10 @@ fun AppTopBar(navController: NavHostController, scaffoldState: ScaffoldState, ac
@Composable
fun MainTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) {
val accountUserState by accountViewModel.userLiveData.observeAsState()
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
val accountUserState by account.userProfile().live.observeAsState()
val accountUser = accountUserState?.user
val relayViewModel: RelayPoolViewModel = viewModel { RelayPoolViewModel() }
@@ -101,6 +105,8 @@ fun MainTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel)
NostrUserProfileDataSource.printCounter()
NostrUserProfileFollowersDataSource.printCounter()
NostrUserProfileFollowsDataSource.printCounter()
println("AAA: " + RelayPool.connectedRelays())
}
) {
Icon(

View File

@@ -113,8 +113,6 @@ fun DrawerContent(navController: NavHostController,
fun ProfileContent(accountUser: User?, modifier: Modifier = Modifier, scaffoldState: ScaffoldState, navController: NavController) {
val coroutineScope = rememberCoroutineScope()
println("AAA " + accountUser?.profilePicture())
Column(modifier = modifier) {
AsyncImage(
model = accountUser?.profilePicture() ?: "https://robohash.org/ohno.png",

View File

@@ -32,7 +32,10 @@ fun ChatroomCompose(baseNote: Note, accountViewModel: AccountViewModel, navContr
val noteState by baseNote.live.observeAsState()
val note = noteState?.note
val accountUserState by accountViewModel.userLiveData.observeAsState()
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
val accountUserState by account.userProfile().live.observeAsState()
val accountUser = accountUserState?.user
if (note?.event == null) {
@@ -51,18 +54,19 @@ fun ChatroomCompose(baseNote: Note, accountViewModel: AccountViewModel, navContr
} else {
note.event?.content
}
channel?.let {
channel?.let { channel ->
ChannelName(
channelPicture = it.profilePicture(),
channelPicture = channel.profilePicture(),
channelTitle = {
Text(
"${it.info.name}",
fontWeight = FontWeight.Bold
"${channel.info.name}",
fontWeight = FontWeight.Bold,
modifier = it
)
},
channelLastTime = note.event?.createdAt,
channelLastContent = "${author?.toBestDisplayName()}: " + description,
onClick = { navController.navigate("Channel/${it.idHex}") })
onClick = { navController.navigate("Channel/${channel.idHex}") })
}
} else {
@@ -82,13 +86,13 @@ fun ChatroomCompose(baseNote: Note, accountViewModel: AccountViewModel, navContr
}
}
userToComposeOn?.let {
userToComposeOn?.let { user ->
ChannelName(
channelPicture = it.profilePicture(),
channelTitle = { UsernameDisplay(it) },
channelPicture = user.profilePicture(),
channelTitle = { UsernameDisplay(user, it) },
channelLastTime = note.event?.createdAt,
channelLastContent = accountViewModel.decrypt(note),
onClick = { navController.navigate("Room/${it.pubkeyHex}") })
onClick = { navController.navigate("Room/${user.pubkeyHex}") })
}
}
@@ -97,7 +101,7 @@ fun ChatroomCompose(baseNote: Note, accountViewModel: AccountViewModel, navContr
@Composable
fun ChannelName(
channelPicture: String,
channelTitle: @Composable () -> Unit,
channelTitle: @Composable (Modifier) -> Unit,
channelLastTime: Long?,
channelLastContent: String?,
onClick: () -> Unit
@@ -122,7 +126,7 @@ fun ChannelName(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(bottom = 4.dp)
) {
channelTitle()
channelTitle(Modifier.weight(1f))
channelLastTime?.let {
Text(

View File

@@ -38,6 +38,8 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
import com.vitorpamplona.amethyst.service.model.ReactionEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.ui.components.RichTextViewer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -46,11 +48,14 @@ val ChatBubbleShapeThem = RoundedCornerShape(3.dp, 15.dp, 15.dp, 15.dp)
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ChatroomMessageCompose(baseNote: Note, accountViewModel: AccountViewModel, navController: NavController) {
fun ChatroomMessageCompose(baseNote: Note, innerQuote: Boolean = false, accountViewModel: AccountViewModel, navController: NavController) {
val noteState by baseNote.live.observeAsState()
val note = noteState?.note
val accountUserState by accountViewModel.userLiveData.observeAsState()
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
val accountUserState by account.userProfile().live.observeAsState()
val accountUser = accountUserState?.user
var popupExpanded by remember { mutableStateOf(false) }
@@ -87,7 +92,7 @@ fun ChatroomMessageCompose(baseNote: Note, accountViewModel: AccountViewModel, n
) {
Row(
horizontalArrangement = alignment,
modifier = Modifier.fillMaxWidth(0.85f)
modifier = Modifier.fillMaxWidth(if (innerQuote) 1f else 0.85f)
) {
Surface(
@@ -103,7 +108,7 @@ fun ChatroomMessageCompose(baseNote: Note, accountViewModel: AccountViewModel, n
modifier = Modifier.padding(start = 10.dp, end = 10.dp, bottom = 5.dp),
) {
if (author != accountUser && note.event is ChannelMessageEvent) {
if (innerQuote || author != accountUser && note.event is ChannelMessageEvent) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = alignment,
@@ -135,6 +140,20 @@ fun ChatroomMessageCompose(baseNote: Note, accountViewModel: AccountViewModel, n
}
}
val replyTo = note.replyTo
if (replyTo != null && replyTo.isNotEmpty()) {
Row(verticalAlignment = Alignment.CenterVertically) {
replyTo.mapIndexed { index, note ->
ChatroomMessageCompose(
note,
innerQuote = true,
accountViewModel = accountViewModel,
navController = navController
)
}
}
}
Row(verticalAlignment = Alignment.CenterVertically) {
val event = note.event
if (event is ChannelCreateEvent) {
@@ -170,7 +189,6 @@ fun ChatroomMessageCompose(baseNote: Note, accountViewModel: AccountViewModel, n
}
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.End,

View File

@@ -31,7 +31,7 @@ fun LikeSetCompose(likeSetCard: LikeSetCard, modifier: Modifier = Modifier, isIn
val noteState by likeSetCard.note.live.observeAsState()
val note = noteState?.note
if (note?.event == null) {
if (note == null) {
BlankNote(Modifier, isInnerNote)
} else {
Column(modifier = modifier) {

View File

@@ -34,6 +34,7 @@ import androidx.navigation.NavController
import coil.compose.AsyncImage
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.toNote
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.ReactionEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.ui.components.RichTextViewer
@@ -50,14 +51,25 @@ fun NoteCompose(baseNote: Note, modifier: Modifier = Modifier, isInnerNote: Bool
var popupExpanded by remember { mutableStateOf(false) }
if (note?.event == null) {
BlankNote(modifier, isInnerNote)
BlankNote(modifier.combinedClickable(
onClick = { },
onLongClick = { popupExpanded = true },
), isInnerNote)
} else {
val authorState by note.author!!.live.observeAsState()
val author = authorState?.user
Column(modifier =
modifier.combinedClickable(
onClick = { navController.navigate("Note/${note.idHex}") },
onClick = {
if (note.event !is ChannelMessageEvent) {
navController.navigate("Note/${note.idHex}")
} else {
note.channel?.let {
navController.navigate("Channel/${it.idHex}")
}
}
},
onLongClick = { popupExpanded = true },
)
) {
@@ -110,12 +122,13 @@ fun NoteCompose(baseNote: Note, modifier: Modifier = Modifier, isInnerNote: Bool
Column(modifier = Modifier.padding(start = if (!isInnerNote) 10.dp else 0.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (author != null)
UsernameDisplay(author)
UsernameDisplay(author, Modifier.weight(1f))
if (note.event !is RepostEvent) {
Text(
timeAgo(note.event?.createdAt),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
maxLines = 1
)
} else {
Text(
@@ -128,6 +141,10 @@ fun NoteCompose(baseNote: Note, modifier: Modifier = Modifier, isInnerNote: Bool
if (note.event is TextNoteEvent && (note.replyTo != null || note.mentions != null)) {
ReplyInformation(note.replyTo, note.mentions, navController)
} else if (note.event is ChannelMessageEvent && (note.replyTo != null || note.mentions != null)) {
note.channel?.let {
ReplyInformationChannel(note.replyTo, note.mentions, it, navController)
}
}
if (note.event is ReactionEvent || note.event is RepostEvent) {
@@ -155,7 +172,9 @@ fun NoteCompose(baseNote: Note, modifier: Modifier = Modifier, isInnerNote: Bool
if (eventContent != null)
RichTextViewer(eventContent, note.event?.tags, note, accountViewModel, navController)
ReactionsRowState(note, accountViewModel)
if (note.event !is ChannelMessageEvent) {
ReactionsRowState(note, accountViewModel)
}
Divider(
modifier = Modifier.padding(top = 10.dp),

View File

@@ -19,6 +19,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import com.google.accompanist.flowlayout.FlowRow
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
@@ -40,6 +41,80 @@ fun ReplyInformation(replyTo: MutableList<Note>?, mentions: List<User>?, prefix:
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
mentions.toSet().forEachIndexed { idx, user ->
val innerUserState by user.live.observeAsState()
val innerUser = innerUserState?.user
innerUser?.let { myUser ->
ClickableText(
AnnotatedString("${prefix}@${myUser.toBestDisplayName()}"),
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary.copy(alpha = 0.52f), fontSize = 13.sp),
onClick = { onUserTagClick(myUser) }
)
if (idx < mentions.size - 2) {
Text(
", ",
fontSize = 13.sp,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
} else if (idx < mentions.size - 1) {
Text(
" and ",
fontSize = 13.sp,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
}
}
}
}
}
}
}
@Composable
fun ReplyInformationChannel(replyTo: MutableList<Note>?, mentions: List<User>?, channel: Channel, navController: NavController) {
ReplyInformationChannel(replyTo, mentions, channel,
onUserTagClick = {
navController.navigate("User/${it.pubkeyHex}")
},
onChannelTagClick = {
navController.navigate("Channel/${it.idHex}")
}
)
}
@Composable
fun ReplyInformationChannel(replyTo: MutableList<Note>?,
mentions: List<User>?,
channel: Channel,
prefix: String = "",
onUserTagClick: (User) -> Unit,
onChannelTagClick: (Channel) -> Unit
) {
FlowRow() {
if (mentions != null && mentions.isNotEmpty()) {
if (replyTo != null && replyTo.isNotEmpty()) {
Text(
"in channel ",
fontSize = 13.sp,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
ClickableText(
AnnotatedString("${channel.info.name} "),
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary.copy(alpha = 0.52f), fontSize = 13.sp),
onClick = { onChannelTagClick(channel) }
)
Text(
"replying to ",
fontSize = 13.sp,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
mentions.toSet().forEachIndexed { idx, user ->
val innerUserState by user.live.observeAsState()
val innerUser = innerUserState?.user

View File

@@ -3,16 +3,21 @@ package com.vitorpamplona.amethyst.ui.note
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import com.vitorpamplona.amethyst.model.User
@Composable
fun UsernameDisplay(user: User) {
fun UsernameDisplay(user: User, weight: Modifier = Modifier) {
if (user.bestUsername() != null || user.bestDisplayName() != null) {
if (user.bestDisplayName().isNullOrBlank()) {
Text(
"@${(user.bestUsername() ?: "")}",
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = weight
)
} else {
Text(
@@ -22,12 +27,18 @@ fun UsernameDisplay(user: User) {
Text(
"@${(user.bestUsername() ?: "")}",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = weight
)
}
} else {
Text(
user.pubkeyDisplayHex,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = weight
)
}
}

View File

@@ -91,13 +91,13 @@ class CardFeedViewModel(val dataSource: NostrDataSource<Note>): ViewModel() {
}
}
val scope = CoroutineScope(Job() + Dispatchers.IO)
var handlerWaiting = false
@Synchronized
fun invalidateData() {
if (handlerWaiting) return
handlerWaiting = true
val scope = CoroutineScope(Job() + Dispatchers.IO)
scope.launch {
delay(100)
refresh()

View File

@@ -28,6 +28,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class NostrChannelFeedViewModel: FeedViewModel(NostrChannelDataSource)
class NostrChatroomListFeedViewModel: FeedViewModel(NostrChatroomListDataSource)
@@ -43,50 +44,39 @@ abstract class FeedViewModel(val dataSource: NostrDataSource<Note>): ViewModel()
val feedContent = _feedContent.asStateFlow()
fun refresh() {
val scope = CoroutineScope(Job() + Dispatchers.IO)
scope.launch {
refreshSuspended()
}
}
viewModelScope.launch(Dispatchers.IO) {
val notes = dataSource.loadTop()
private fun refreshSuspended() {
val notes = dataSource.loadTop()
val oldNotesState = feedContent.value
if (oldNotesState is FeedState.Loaded) {
if (notes != oldNotesState.feed) {
updateFeed(notes)
val oldNotesState = feedContent.value
if (oldNotesState is FeedState.Loaded) {
if (notes != oldNotesState.feed) {
withContext(Dispatchers.Main) {
updateFeed(notes)
}
}
} else {
withContext(Dispatchers.Main) {
updateFeed(notes)
}
}
} else {
updateFeed(notes)
}
}
fun updateFeed(notes: List<Note>) {
val scope = CoroutineScope(Job() + Dispatchers.Main)
scope.launch {
if (notes.isEmpty()) {
_feedContent.update { FeedState.Empty }
} else {
_feedContent.update { FeedState.Loaded(notes) }
}
if (notes.isEmpty()) {
_feedContent.update { FeedState.Empty }
} else {
_feedContent.update { FeedState.Loaded(notes) }
}
}
fun refreshCurrentList() {
val state = feedContent.value
if (state is FeedState.Loaded) {
_feedContent.update { FeedState.Loaded(state.feed) }
}
}
val scope = CoroutineScope(Job() + Dispatchers.IO)
var handlerWaiting = false
@Synchronized
fun invalidateData() {
if (handlerWaiting) return
handlerWaiting = true
val scope = CoroutineScope(Job() + Dispatchers.IO)
scope.launch {
delay(100)
refresh()

View File

@@ -63,20 +63,13 @@ open class UserFeedViewModel(val dataSource: NostrDataSource<User>): ViewModel()
}
}
fun refreshCurrentList() {
val state = feedContent.value
if (state is UserFeedState.Loaded) {
_feedContent.update { UserFeedState.Loaded(state.feed) }
}
}
val scope = CoroutineScope(Job() + Dispatchers.IO)
var handlerWaiting = false
@Synchronized
fun invalidateData() {
if (handlerWaiting) return
handlerWaiting = true
val scope = CoroutineScope(Job() + Dispatchers.IO)
scope.launch {
delay(100)
refresh()

View File

@@ -10,7 +10,6 @@ import com.vitorpamplona.amethyst.model.UserState
class AccountViewModel(private val account: Account): ViewModel() {
val accountLiveData: LiveData<AccountState> = Transformations.map(account.live) { it }
val userLiveData: LiveData<UserState> = Transformations.map(account.userProfile().live) { it }
fun reactTo(note: Note) {
account.reactTo(note)

View File

@@ -21,10 +21,6 @@ fun NotificationScreen(accountViewModel: AccountViewModel, navController: NavCon
if (account != null) {
val feedViewModel: CardFeedViewModel = viewModel { CardFeedViewModel( NostrNotificationDataSource ) }
LaunchedEffect(Unit) {
feedViewModel.refresh()
}
Column(Modifier.fillMaxHeight()) {
Column(
modifier = Modifier.padding(vertical = 0.dp)

View File

@@ -30,7 +30,10 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
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.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -51,31 +54,29 @@ import com.google.accompanist.pager.HorizontalPager
import com.google.accompanist.pager.pagerTabIndicatorOffset
import com.google.accompanist.pager.rememberPagerState
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.toNote
import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource
import com.vitorpamplona.amethyst.service.NostrUserProfileFollowersDataSource
import com.vitorpamplona.amethyst.service.NostrUserProfileFollowsDataSource
import com.vitorpamplona.amethyst.ui.actions.NewChannelView
import com.vitorpamplona.amethyst.ui.actions.NewUserMetadataView
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.coroutines.launch
import nostr.postr.toNpub
data class TabRowItem(
val title: String,
val screen: @Composable () -> Unit,
)
@OptIn(ExperimentalPagerApi::class)
@Composable
fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, navController: NavController) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account
val account = accountState?.account ?: return
val accountUserState by accountViewModel.userLiveData.observeAsState()
val accountUserState by account.userProfile().live.observeAsState()
val accountUser = accountUserState?.user
if (userId != null && account != null && accountUser != null) {
if (userId != null && accountUser != null) {
DisposableEffect(account) {
NostrUserProfileDataSource.loadUserProfile(userId)
NostrUserProfileFollowersDataSource.loadUserProfile(userId)
@@ -105,14 +106,18 @@ fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, navContro
model = banner,
contentDescription = "Profile Image",
contentScale = ContentScale.FillWidth,
modifier = Modifier.fillMaxWidth().height(125.dp)
modifier = Modifier
.fillMaxWidth()
.height(125.dp)
)
} else {
Image(
painter = painterResource(R.drawable.profile_banner),
contentDescription = "Profile Banner",
contentScale = ContentScale.FillWidth,
modifier = Modifier.fillMaxWidth().height(125.dp)
modifier = Modifier
.fillMaxWidth()
.height(125.dp)
)
}
@@ -141,9 +146,9 @@ fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, navContro
NPubCopyButton(user)
if (accountUser == user) {
EditButton()
EditButton(account)
} else {
if (accountUser.isFollowing(user) == true) {
if (accountUser.isFollowing(user)) {
UnfollowButton { account.unfollow(user) }
} else {
FollowButton { account.follow(user) }
@@ -300,10 +305,17 @@ private fun MessageButton(user: User, navController: NavController) {
}
@Composable
private fun EditButton() {
private fun EditButton(account: Account) {
var wantsToEdit by remember {
mutableStateOf(false)
}
if (wantsToEdit)
NewUserMetadataView({ wantsToEdit = false }, account)
Button(
modifier = Modifier.padding(horizontal = 3.dp),
onClick = {},
onClick = { wantsToEdit = true },
shape = RoundedCornerShape(20.dp),
colors = ButtonDefaults
.buttonColors(

View File

@@ -15,12 +15,10 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.ClickableText
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.Divider
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.material.TextField
@@ -38,7 +36,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.TextFieldValue
@@ -51,9 +48,6 @@ import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.NostrGlobalDataSource
import com.vitorpamplona.amethyst.ui.actions.CloseButton
import com.vitorpamplona.amethyst.ui.actions.SearchButton
import com.vitorpamplona.amethyst.ui.note.ChannelName
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay

View File

@@ -20,4 +20,5 @@ kotlin.code.style=official
# Enables namespacing of each library's R class so that its R class includes only the
# resources declared in the library itself and none from the library's dependencies,
# thereby reducing the size of the R class for that library
android.nonTransitiveRClass=true
android.nonTransitiveRClass=true
android.enableR8.fullMode=true