NIP-39 Support

This commit is contained in:
Vitor Pamplona
2023-03-09 13:24:32 -05:00
parent 56433a3ad3
commit 3b582636f4
10 changed files with 452 additions and 152 deletions
@@ -9,6 +9,7 @@ import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
import com.vitorpamplona.amethyst.service.model.Contact
import com.vitorpamplona.amethyst.service.model.ContactListEvent
import com.vitorpamplona.amethyst.service.model.DeletionEvent
import com.vitorpamplona.amethyst.service.model.IdentityClaim
import com.vitorpamplona.amethyst.service.model.LnZapRequestEvent
import com.vitorpamplona.amethyst.service.model.MetadataEvent
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
@@ -106,11 +107,11 @@ class Account(
}
}
fun sendNewUserMetadata(toString: String) {
fun sendNewUserMetadata(toString: String, identities: List<IdentityClaim>) {
if (!isWriteable()) return
loggedIn.privKey?.let {
val event = MetadataEvent.create(toString, loggedIn.privKey!!)
val event = MetadataEvent.create(toString, identities, loggedIn.privKey!!)
Client.send(event)
LocalCache.consume(event)
}
@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.service.model
import android.util.Log
import com.google.gson.Gson
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
@@ -14,6 +15,127 @@ data class ContactMetaData(
val nip05: String?
)
abstract class IdentityClaim(
var identity: String,
var proof: String
) {
abstract fun toProofUrl(): String
abstract fun toIcon(): Int
abstract fun toDescriptor(): Int
abstract fun platform(): String
fun platformIdentity() = "${platform()}:$identity"
companion object {
fun create(platformIdentity: String, proof: String): IdentityClaim? {
val platformIdentity = platformIdentity.split(':')
val platform = platformIdentity[0]
val identity = platformIdentity[1]
return when (platform.lowercase()) {
GitHubIdentity.platform -> GitHubIdentity(identity, proof)
TwitterIdentity.platform -> TwitterIdentity(identity, proof)
TelegramIdentity.platform -> TelegramIdentity(identity, proof)
MastodonIdentity.platform -> MastodonIdentity(identity, proof)
else -> throw IllegalArgumentException("Platform $platform not supported")
}
}
}
}
class GitHubIdentity(
identity: String,
proof: String
) : IdentityClaim(identity, proof) {
override fun toProofUrl() = "https://gist.github.com/$identity/$proof"
override fun platform() = platform
override fun toIcon() = R.drawable.github
override fun toDescriptor() = R.string.github
companion object {
val platform = "github"
fun parseProofUrl(proofUrl: String): GitHubIdentity? {
return try {
if (proofUrl.isBlank()) return null
val path = proofUrl.removePrefix("https://gist.github.com/").split("?")[0].split("/")
GitHubIdentity(path[0], path[1])
} catch (e: Exception) {
null
}
}
}
}
class TwitterIdentity(
identity: String,
proof: String
) : IdentityClaim(identity, proof) {
override fun toProofUrl() = "https://twitter.com/$identity/status/$proof"
override fun platform() = platform
override fun toIcon() = R.drawable.twitter
override fun toDescriptor() = R.string.twitter
companion object {
val platform = "twitter"
fun parseProofUrl(proofUrl: String): TwitterIdentity? {
return try {
if (proofUrl.isBlank()) return null
val path = proofUrl.removePrefix("https://twitter.com/").split("?")[0].split("/")
TwitterIdentity(path[0], path[2])
} catch (e: Exception) {
null
}
}
}
}
class TelegramIdentity(
identity: String,
proof: String
) : IdentityClaim(identity, proof) {
override fun toProofUrl() = "https://t.me/$proof"
override fun platform() = platform
override fun toIcon() = R.drawable.telegram
override fun toDescriptor() = R.string.telegram
companion object {
val platform = "telegram"
}
}
class MastodonIdentity(
identity: String,
proof: String
) : IdentityClaim(identity, proof) {
override fun toProofUrl() = "https://$identity/$proof"
override fun platform() = platform
override fun toIcon() = R.drawable.mastodon
override fun toDescriptor() = R.string.mastodon
companion object {
val platform = "mastodon"
fun parseProofUrl(proofUrl: String): MastodonIdentity? {
return try {
if (proofUrl.isBlank()) return null
val path = proofUrl.removePrefix("https://").split("?")[0].split("/")
return MastodonIdentity(path[0], path[1])
} catch (e: Exception) {
null
}
}
}
}
class MetadataEvent(
id: HexKey,
pubKey: HexKey,
@@ -29,18 +151,31 @@ class MetadataEvent(
null
}
fun identityClaims() = tags.filter { it.firstOrNull() == "i" }.mapNotNull {
try {
IdentityClaim.create(it.get(1), it.get(2))
} catch (e: Exception) {
Log.e("MetadataEvent", "Can't parse identity [${it.joinToString { "," }}]", e)
null
}
}
companion object {
const val kind = 0
val gson = Gson()
fun create(contactMetaData: ContactMetaData, privateKey: ByteArray, createdAt: Long = Date().time / 1000): MetadataEvent {
return create(gson.toJson(contactMetaData), privateKey, createdAt = createdAt)
fun create(contactMetaData: ContactMetaData, identities: List<IdentityClaim>, privateKey: ByteArray, createdAt: Long = Date().time / 1000): MetadataEvent {
return create(gson.toJson(contactMetaData), identities, privateKey, createdAt = createdAt)
}
fun create(contactMetaData: String, privateKey: ByteArray, createdAt: Long = Date().time / 1000): MetadataEvent {
fun create(contactMetaData: String, identities: List<IdentityClaim>, privateKey: ByteArray, createdAt: Long = Date().time / 1000): MetadataEvent {
val content = contactMetaData
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val tags = listOf<List<String>>()
val tags = mutableListOf<List<String>>()
identities?.forEach {
tags.add(listOf("i", it.platformIdentity(), it.proof))
}
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
return MetadataEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
@@ -7,7 +7,9 @@ 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.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Surface
@@ -15,7 +17,6 @@ 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.res.stringResource
import androidx.compose.ui.text.input.KeyboardCapitalization
@@ -26,7 +27,6 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun NewUserMetadataView(onClose: () -> Unit, account: Account) {
val postViewModel: NewUserMetadataViewModel = viewModel()
@@ -66,158 +66,207 @@ fun NewUserMetadataView(onClose: () -> Unit, account: Account) {
)
}
Spacer(modifier = Modifier.height(10.dp))
Column(
modifier = Modifier.padding(10.dp).verticalScroll(rememberScrollState())
) {
Row(
modifier = Modifier.fillMaxWidth(1f),
verticalAlignment = Alignment.CenterVertically
) {
OutlinedTextField(
label = { Text(text = stringResource(R.string.display_name)) },
modifier = Modifier.weight(1f),
value = postViewModel.displayName.value,
onValueChange = { postViewModel.displayName.value = it },
placeholder = {
Text(
text = stringResource(R.string.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 = stringResource(R.string.username)) },
modifier = Modifier.weight(1f),
value = postViewModel.userName.value,
onValueChange = { postViewModel.userName.value = it },
placeholder = {
Text(
text = stringResource(R.string.my_username),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
singleLine = true
)
}
Spacer(modifier = Modifier.height(10.dp))
Row(modifier = Modifier.fillMaxWidth(1f), verticalAlignment = Alignment.CenterVertically) {
OutlinedTextField(
label = { Text(text = stringResource(R.string.display_name)) },
modifier = Modifier.weight(1f),
value = postViewModel.displayName.value,
onValueChange = { postViewModel.displayName.value = it },
label = { Text(text = stringResource(R.string.about_me)) },
modifier = Modifier
.fillMaxWidth()
.height(100.dp),
value = postViewModel.about.value,
onValueChange = { postViewModel.about.value = it },
placeholder = {
Text(
text = stringResource(R.string.my_display_name),
text = stringResource(id = R.string.about_me),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
keyboardOptions = KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences
),
singleLine = true
maxLines = 10
)
Text("@", Modifier.padding(5.dp))
Spacer(modifier = Modifier.height(10.dp))
OutlinedTextField(
label = { Text(text = stringResource(R.string.username)) },
modifier = Modifier.weight(1f),
value = postViewModel.userName.value,
onValueChange = { postViewModel.userName.value = it },
label = { Text(text = stringResource(R.string.avatar_url)) },
modifier = Modifier.fillMaxWidth(),
value = postViewModel.picture.value,
onValueChange = { postViewModel.picture.value = it },
placeholder = {
Text(
text = stringResource(R.string.my_username),
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 = stringResource(R.string.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 = stringResource(R.string.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 = stringResource(R.string.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 = stringResource(R.string.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
)
Spacer(modifier = Modifier.height(10.dp))
OutlinedTextField(
label = { Text(text = stringResource(R.string.ln_url_outdated)) },
modifier = Modifier.fillMaxWidth(),
value = postViewModel.lnURL.value,
onValueChange = { postViewModel.lnURL.value = it },
placeholder = {
Text(
text = stringResource(R.string.lnurl),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
}
)
Spacer(modifier = Modifier.height(10.dp))
OutlinedTextField(
label = { Text(text = stringResource(R.string.twitter)) },
modifier = Modifier.fillMaxWidth(),
value = postViewModel.twitter.value,
onValueChange = { postViewModel.twitter.value = it },
placeholder = {
Text(
text = stringResource(R.string.twitter_proof_url_template),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
}
)
Spacer(modifier = Modifier.height(10.dp))
OutlinedTextField(
label = { Text(text = stringResource(R.string.mastodon)) },
modifier = Modifier.fillMaxWidth(),
value = postViewModel.mastodon.value,
onValueChange = { postViewModel.mastodon.value = it },
placeholder = {
Text(
text = stringResource(R.string.mastodon_proof_url_template),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
}
)
Spacer(modifier = Modifier.height(10.dp))
OutlinedTextField(
label = { Text(text = stringResource(R.string.github)) },
modifier = Modifier.fillMaxWidth(),
value = postViewModel.github.value,
onValueChange = { postViewModel.github.value = it },
placeholder = {
Text(
text = stringResource(R.string.github_proof_url_template),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
}
)
}
Spacer(modifier = Modifier.height(10.dp))
OutlinedTextField(
label = { Text(text = stringResource(R.string.about_me)) },
modifier = Modifier
.fillMaxWidth()
.height(100.dp),
value = postViewModel.about.value,
onValueChange = { postViewModel.about.value = it },
placeholder = {
Text(
text = stringResource(id = R.string.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 = stringResource(R.string.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 = stringResource(R.string.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 = stringResource(R.string.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 = stringResource(R.string.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 = stringResource(R.string.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
)
Spacer(modifier = Modifier.height(10.dp))
OutlinedTextField(
label = { Text(text = stringResource(R.string.ln_url_outdated)) },
modifier = Modifier.fillMaxWidth(),
value = postViewModel.lnURL.value,
onValueChange = { postViewModel.lnURL.value = it },
placeholder = {
Text(
text = stringResource(R.string.lnurl),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
singleLine = true
)
}
}
}
@@ -5,6 +5,9 @@ import androidx.lifecycle.ViewModel
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.node.ObjectNode
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.model.GitHubIdentity
import com.vitorpamplona.amethyst.service.model.MastodonIdentity
import com.vitorpamplona.amethyst.service.model.TwitterIdentity
import java.io.ByteArrayInputStream
import java.io.StringWriter
@@ -23,6 +26,10 @@ class NewUserMetadataViewModel : ViewModel() {
val lnAddress = mutableStateOf("")
val lnURL = mutableStateOf("")
val twitter = mutableStateOf("")
val github = mutableStateOf("")
val mastodon = mutableStateOf("")
fun load(account: Account) {
this.account = account
@@ -36,6 +43,19 @@ class NewUserMetadataViewModel : ViewModel() {
nip05.value = it.info?.nip05 ?: ""
lnAddress.value = it.info?.lud16 ?: ""
lnURL.value = it.info?.lud06 ?: ""
twitter.value = ""
github.value = ""
mastodon.value = ""
// TODO: Validate Telegram input, somehow.
it.info?.latestMetadata?.identityClaims()?.forEach {
when (it) {
is TwitterIdentity -> twitter.value = it.toProofUrl()
is GitHubIdentity -> github.value = it.toProofUrl()
is MastodonIdentity -> mastodon.value = it.toProofUrl()
}
}
}
}
@@ -61,10 +81,34 @@ class NewUserMetadataViewModel : ViewModel() {
currentJson.put("lud16", lnAddress.value.trim())
currentJson.put("lud06", lnURL.value.trim())
var claims = latest?.identityClaims() ?: emptyList()
if (twitter.value.isBlank()) {
// delete twitter
claims = claims.filter { it !is TwitterIdentity }
}
if (github.value.isBlank()) {
// delete github
claims = claims.filter { it !is GitHubIdentity }
}
if (mastodon.value.isBlank()) {
// delete mastodon
claims = claims.filter { it !is MastodonIdentity }
}
// Updates while keeping other identities intact
val newClaims = listOfNotNull(
TwitterIdentity.parseProofUrl(twitter.value),
GitHubIdentity.parseProofUrl(github.value),
MastodonIdentity.parseProofUrl(mastodon.value)
) + claims.filter { it !is TwitterIdentity && it !is GitHubIdentity && it !is MastodonIdentity }
val writer = StringWriter()
ObjectMapper().writeValue(writer, currentJson)
account.sendNewUserMetadata(writer.buffer.toString())
account.sendNewUserMetadata(writer.buffer.toString(), newClaims)
clear()
}
@@ -79,5 +123,8 @@ class NewUserMetadataViewModel : ViewModel() {
nip05.value = ""
lnAddress.value = ""
lnURL.value = ""
twitter.value = ""
github.value = ""
mastodon.value = ""
}
}
@@ -59,6 +59,7 @@ import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource
import com.vitorpamplona.amethyst.service.model.BadgeDefinitionEvent
import com.vitorpamplona.amethyst.service.model.BadgeProfilesEvent
import com.vitorpamplona.amethyst.service.model.IdentityClaim
import com.vitorpamplona.amethyst.service.model.ReportEvent
import com.vitorpamplona.amethyst.ui.actions.NewUserMetadataView
import com.vitorpamplona.amethyst.ui.components.AsyncImageProxy
@@ -431,6 +432,24 @@ private fun DrawAdditionalInfo(baseUser: User, account: Account, navController:
}
}
userBadge.acceptedBadges?.let { note ->
(note.event as? BadgeProfilesEvent)?.let { event ->
FlowRow(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = 5.dp)) {
event.badgeAwardEvents().forEach { badgeAwardEvent ->
val baseNote = LocalCache.notes[badgeAwardEvent]
if (baseNote != null) {
val badgeAwardState by baseNote.live().metadata.observeAsState()
val baseBadgeDefinition = badgeAwardState?.note?.replyTo?.firstOrNull()
if (baseBadgeDefinition != null) {
BadgeThumb(baseBadgeDefinition, navController, 50.dp)
}
}
}
}
}
}
DisplayNip05ProfileStatus(user)
val website = user.info?.website
@@ -484,20 +503,25 @@ private fun DrawAdditionalInfo(baseUser: User, account: Account, navController:
}
}
userBadge.acceptedBadges?.let { note ->
(note.event as? BadgeProfilesEvent)?.let { event ->
FlowRow(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = 5.dp)) {
event.badgeAwardEvents().forEach { badgeAwardEvent ->
val baseNote = LocalCache.notes[badgeAwardEvent]
if (baseNote != null) {
val badgeAwardState by baseNote.live().metadata.observeAsState()
val baseBadgeDefinition = badgeAwardState?.note?.replyTo?.firstOrNull()
val identities = user.info?.latestMetadata?.identityClaims()
if (!identities.isNullOrEmpty()) {
identities.forEach { identity: IdentityClaim ->
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
tint = Color.Unspecified,
painter = painterResource(id = identity.toIcon()),
contentDescription = stringResource(identity.toDescriptor()),
modifier = Modifier.size(16.dp)
)
if (baseBadgeDefinition != null) {
BadgeThumb(baseBadgeDefinition, navController, 50.dp)
}
}
}
ClickableText(
text = AnnotatedString(identity.identity),
onClick = { runCatching { uri.openUri(identity.toProofUrl()) } },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary),
modifier = Modifier
.padding(top = 1.dp, bottom = 1.dp, start = 5.dp)
.weight(1f)
)
}
}
}