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 591193b04d..004452884a 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -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 = 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>() + 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 = 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 = 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) @@ -221,6 +240,8 @@ class Account(val loggedIn: Persona, val followingChannels: MutableSet = private fun refreshObservers() { live.refresh() } + + } class AccountLiveData(private val account: Account): LiveData(AccountState(account)) { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 117eae6f5d..d6e2e053f5 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -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() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt index d8fb461707..2f23a137a0 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt @@ -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()) val follows = Collections.synchronizedSet(mutableSetOf()) 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 new file mode 100644 index 0000000000..92a4e2f7be --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataView.kt @@ -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 + ) + + } + } + } +} 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 new file mode 100644 index 0000000000..63d913980f --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt @@ -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 = "" + } +} \ No newline at end of file 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 306b867648..5df8b1e6a3 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 @@ -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,21 +54,19 @@ 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) { @@ -75,7 +76,7 @@ fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, navContro 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) @@ -93,6 +94,8 @@ fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, navContro val userState by baseUser.live.observeAsState() val user = userState?.user ?: return + println("AAA Surface recompose") + Surface( modifier = Modifier.fillMaxWidth(), color = MaterialTheme.colors.background @@ -105,14 +108,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 +148,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 +307,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(