Adds support for nip54

This commit is contained in:
Vitor Pamplona
2023-12-11 12:51:53 -05:00
parent 49e1da8812
commit 3d225423ef
12 changed files with 143 additions and 22 deletions
@@ -11,6 +11,7 @@ import com.vitorpamplona.amethyst.ui.components.ZoomableUrlImage
import com.vitorpamplona.amethyst.ui.components.ZoomableUrlVideo import com.vitorpamplona.amethyst.ui.components.ZoomableUrlVideo
import com.vitorpamplona.amethyst.ui.components.hashTagsPattern import com.vitorpamplona.amethyst.ui.components.hashTagsPattern
import com.vitorpamplona.amethyst.ui.components.imageExtensions import com.vitorpamplona.amethyst.ui.components.imageExtensions
import com.vitorpamplona.amethyst.ui.components.removeQueryParams
import com.vitorpamplona.amethyst.ui.components.tagIndex import com.vitorpamplona.amethyst.ui.components.tagIndex
import com.vitorpamplona.amethyst.ui.components.videoExtensions import com.vitorpamplona.amethyst.ui.components.videoExtensions
import com.vitorpamplona.quartz.events.ImmutableListOfLists import com.vitorpamplona.quartz.events.ImmutableListOfLists
@@ -21,6 +22,8 @@ import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableMap import kotlinx.collections.immutable.toImmutableMap
import kotlinx.collections.immutable.toImmutableSet import kotlinx.collections.immutable.toImmutableSet
import java.net.URI
import java.net.URLDecoder
import java.util.regex.Pattern import java.util.regex.Pattern
@Immutable @Immutable
@@ -85,11 +88,26 @@ class RichTextParser() {
} }
val imagesForPager = urlSet.mapNotNull { fullUrl -> val imagesForPager = urlSet.mapNotNull { fullUrl ->
val removedParamsFromUrl = fullUrl.split("?")[0].lowercase() val removedParamsFromUrl = removeQueryParams(fullUrl)
if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) { if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) {
ZoomableUrlImage(fullUrl) val frags = URI(fullUrl).fragments()
println("Image $fullUrl $frags")
ZoomableUrlImage(
url = fullUrl,
description = frags["alt"]?.let { URLDecoder.decode(it, "UTF-8") },
hash = frags["x"]?.let { URLDecoder.decode(it, "UTF-8") },
blurhash = frags["blurhash"]?.let { URLDecoder.decode(it, "UTF-8") },
dim = frags["dim"]?.let { URLDecoder.decode(it, "UTF-8") }
)
} else if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) { } else if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) {
ZoomableUrlVideo(fullUrl) val frags = URI(fullUrl).fragments()
ZoomableUrlVideo(
url = fullUrl,
description = frags["alt"]?.let { URLDecoder.decode(it, "UTF-8") },
hash = frags["x"]?.let { URLDecoder.decode(it, "UTF-8") },
dim = frags["blurhash"]?.let { URLDecoder.decode(it, "UTF-8") },
uri = frags["dim"]?.let { URLDecoder.decode(it, "UTF-8") }
)
} else { } else {
null null
} }
@@ -110,6 +128,16 @@ class RichTextParser() {
) )
} }
private fun URI.fragments(): Map<String, String> {
if (rawFragment == null) return emptyMap()
return rawFragment.split('&').associate {
val parts = it.split('=')
val name = parts.firstOrNull() ?: ""
val value = parts.getOrNull(1) ?: ""
Pair(name, value)
}
}
private fun findTextSegments(content: String, images: Set<String>, urls: Set<String>, emojis: Map<String, String>, tags: ImmutableListOfLists<String>): ImmutableList<ParagraphState> { private fun findTextSegments(content: String, images: Set<String>, urls: Set<String>, emojis: Map<String, String>, tags: ImmutableListOfLists<String>): ImmutableList<ParagraphState> {
var paragraphSegments = persistentListOf<ParagraphState>() var paragraphSegments = persistentListOf<ParagraphState>()
@@ -460,8 +460,14 @@ fun NewPostView(
if (myUrlPreview != null) { if (myUrlPreview != null) {
Row(modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp)) { Row(modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp)) {
if (isValidURL(myUrlPreview)) { if (isValidURL(myUrlPreview)) {
val removedParamsFromUrl = val removedParamsFromUrl = if (myUrlPreview.contains("?")) {
myUrlPreview.split("?")[0].lowercase() myUrlPreview.split("?")[0].lowercase()
} else if (myUrlPreview.contains("#")) {
myUrlPreview.split("#")[0].lowercase()
} else {
myUrlPreview
}
if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) { if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) {
AsyncImage( AsyncImage(
model = myUrlPreview, model = myUrlPreview,
@@ -48,6 +48,7 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.net.URLEncoder
enum class UserSuggestionAnchor { enum class UserSuggestionAnchor {
MAIN_MESSAGE, MAIN_MESSAGE,
@@ -376,7 +377,7 @@ open class NewPostViewModel() : ViewModel() {
onReady = { fileUri, contentType, size -> onReady = { fileUri, contentType, size ->
if (server.isNip95) { if (server.isNip95) {
contentResolver.openInputStream(fileUri)?.use { contentResolver.openInputStream(fileUri)?.use {
createNIP95Record(it.readBytes(), contentType, alt, sensitiveContent, relayList = relayList) createNIP95Record(it.readBytes(), contentType, alt, sensitiveContent)
} }
} else { } else {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
@@ -400,11 +401,12 @@ open class NewPostViewModel() : ViewModel() {
sensitiveContent = sensitiveContent sensitiveContent = sensitiveContent
) )
} else { } else {
val url = result.tags?.firstOrNull() { it.size > 1 && it[0] == "url" }?.get(1) noNIP94(
uploadingResult = result,
isUploadingImage = false localContentType = contentType,
message = TextFieldValue(message.text + "\n" + url) alt = alt,
urlPreview = findUrlInMessage() sensitiveContent = sensitiveContent
)
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.e( Log.e(
@@ -652,7 +654,8 @@ open class NewPostViewModel() : ViewModel() {
account?.createHeader(imageUrl, magnet, header, alt, sensitiveContent, originalHash) { event -> account?.createHeader(imageUrl, magnet, header, alt, sensitiveContent, originalHash) { event ->
isUploadingImage = false isUploadingImage = false
nip94attachments = nip94attachments + event nip94attachments = nip94attachments + event
message = TextFieldValue(message.text + "\n" + imageUrl) val contentWarning = if (sensitiveContent) "" else null
message = TextFieldValue(message.text + "\n" + addInlineMetadataAsNIP54(imageUrl, header.dim, header.mimeType, alt, header.blurHash, header.hash, contentWarning))
urlPreview = findUrlInMessage() urlPreview = findUrlInMessage()
} }
}, },
@@ -665,12 +668,76 @@ open class NewPostViewModel() : ViewModel() {
) )
} }
suspend fun noNIP94(
uploadingResult: Nip96Uploader.PartialEvent,
localContentType: String?,
alt: String?,
sensitiveContent: Boolean
) {
// Images don't seem to be ready immediately after upload
val imageUrl = uploadingResult.tags?.firstOrNull() { it.size > 1 && it[0] == "url" }?.get(1)
val remoteMimeType = uploadingResult.tags?.firstOrNull() { it.size > 1 && it[0] == "m" }?.get(1)?.ifBlank { null }
val dim = uploadingResult.tags?.firstOrNull() { it.size > 1 && it[0] == "dim" }?.get(1)?.ifBlank { null }
if (imageUrl.isNullOrBlank()) {
Log.e("ImageDownload", "Couldn't download image from server")
cancel()
isUploadingImage = false
viewModelScope.launch {
imageUploadingError.emit("Server failed to return a url")
}
return
}
FileHeader.prepare(
fileUrl = imageUrl,
mimeType = remoteMimeType ?: localContentType,
dimPrecomputed = dim,
onReady = { header: FileHeader ->
isUploadingImage = false
val contentWarning = if (sensitiveContent) "" else null
message = TextFieldValue(message.text + "\n" + addInlineMetadataAsNIP54(imageUrl, header.dim, header.mimeType, alt, header.blurHash, header.hash, contentWarning))
urlPreview = findUrlInMessage()
},
onError = {
isUploadingImage = false
viewModelScope.launch {
imageUploadingError.emit("Failed to upload the image / video")
}
}
)
}
fun addInlineMetadataAsNIP54(
imageUrl: String,
dim: String?,
m: String?,
alt: String?,
blurHash: String?,
x: String?,
sensitiveContent: String?
): String {
val extension = listOfNotNull(
m?.ifBlank { null }?.let { "m=${URLEncoder.encode(it, "utf-8")}" },
dim?.ifBlank { null }?.let { "dim=${URLEncoder.encode(it, "utf-8")}" },
alt?.ifBlank { null }?.let { "alt=${URLEncoder.encode(it, "utf-8")}" },
blurHash?.ifBlank { null }?.let { "blurhash=${URLEncoder.encode(it, "utf-8")}" },
x?.ifBlank { null }?.let { "x=${URLEncoder.encode(it, "utf-8")}" },
sensitiveContent?.ifBlank { null }?.let { "content-warning=${URLEncoder.encode(it, "utf-8")}" }
).joinToString("&")
return if (imageUrl.contains("#")) {
"$imageUrl&$extension"
} else {
"$imageUrl#$extension"
}
}
fun createNIP95Record( fun createNIP95Record(
bytes: ByteArray, bytes: ByteArray,
mimeType: String?, mimeType: String?,
alt: String?, alt: String?,
sensitiveContent: Boolean, sensitiveContent: Boolean
relayList: List<Relay>? = null
) { ) {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
FileHeader.prepare( FileHeader.prepare(
@@ -93,7 +93,7 @@ class MarkdownParser {
content.split('\n').forEach { paragraph -> content.split('\n').forEach { paragraph ->
paragraph.split(' ').forEach { word: String -> paragraph.split(' ').forEach { word: String ->
if (isValidURL(word)) { if (isValidURL(word)) {
val removedParamsFromUrl = word.split("?")[0].lowercase() val removedParamsFromUrl = removeQueryParams(word)
if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) { if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) {
returnContent += "![]($word) " returnContent += "![]($word) "
} else { } else {
@@ -95,6 +95,16 @@ val videoExtensions = listOf("mp4", "avi", "wmv", "mpg", "amv", "webm", "mov", "
val tagIndex = Pattern.compile("\\#\\[([0-9]+)\\](.*)") val tagIndex = Pattern.compile("\\#\\[([0-9]+)\\](.*)")
val hashTagsPattern: Pattern = Pattern.compile("#([^\\s!@#\$%^&*()=+./,\\[{\\]};:'\"?><]+)(.*)", Pattern.CASE_INSENSITIVE) val hashTagsPattern: Pattern = Pattern.compile("#([^\\s!@#\$%^&*()=+./,\\[{\\]};:'\"?><]+)(.*)", Pattern.CASE_INSENSITIVE)
fun removeQueryParams(fullUrl: String): String {
return if (fullUrl.contains("?")) {
fullUrl.split("?")[0].lowercase()
} else if (fullUrl.contains("#")) {
fullUrl.split("#")[0].lowercase()
} else {
fullUrl
}
}
fun isValidURL(url: String?): Boolean { fun isValidURL(url: String?): Boolean {
return try { return try {
URL(url).toURI() URL(url).toURI()
@@ -182,7 +182,7 @@ class ZoomableLocalVideo(
) : ZoomablePreloadedContent(localFile, description, mimeType, isVerified, dim, uri) ) : ZoomablePreloadedContent(localFile, description, mimeType, isVerified, dim, uri)
fun figureOutMimeType(fullUrl: String): ZoomableContent { fun figureOutMimeType(fullUrl: String): ZoomableContent {
val removedParamsFromUrl = fullUrl.split("?")[0].lowercase() val removedParamsFromUrl = removeQueryParams(fullUrl)
val isImage = imageExtensions.any { removedParamsFromUrl.endsWith(it) } val isImage = imageExtensions.any { removedParamsFromUrl.endsWith(it) }
val isVideo = videoExtensions.any { removedParamsFromUrl.endsWith(it) } val isVideo = videoExtensions.any { removedParamsFromUrl.endsWith(it) }
@@ -389,6 +389,7 @@ private fun UrlImageView(
} }
if (showImage.value) { if (showImage.value) {
/*
AsyncImage( AsyncImage(
model = content.url, model = content.url,
contentDescription = content.description, contentDescription = content.description,
@@ -398,6 +399,8 @@ private fun UrlImageView(
painterState.value = it painterState.value = it
} }
) )
*/
} }
AddedImageFeatures( AddedImageFeatures(
@@ -113,6 +113,7 @@ import com.vitorpamplona.amethyst.ui.components.ZoomableUrlImage
import com.vitorpamplona.amethyst.ui.components.ZoomableUrlVideo import com.vitorpamplona.amethyst.ui.components.ZoomableUrlVideo
import com.vitorpamplona.amethyst.ui.components.figureOutMimeType import com.vitorpamplona.amethyst.ui.components.figureOutMimeType
import com.vitorpamplona.amethyst.ui.components.imageExtensions import com.vitorpamplona.amethyst.ui.components.imageExtensions
import com.vitorpamplona.amethyst.ui.components.removeQueryParams
import com.vitorpamplona.amethyst.ui.screen.equalImmutableLists import com.vitorpamplona.amethyst.ui.screen.equalImmutableLists
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChannelHeader import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChannelHeader
@@ -3454,7 +3455,7 @@ fun FileHeaderDisplay(note: Note, roundedCorner: Boolean, accountViewModel: Acco
val hash = event.hash() val hash = event.hash()
val dimensions = event.dimensions() val dimensions = event.dimensions()
val description = event.alt() ?: event.content val description = event.alt() ?: event.content
val isImage = imageExtensions.any { fullUrl.split("?")[0].lowercase().endsWith(it) } val isImage = imageExtensions.any { removeQueryParams(fullUrl).lowercase().endsWith(it) }
val uri = note.toNostrUri() val uri = note.toNostrUri()
mutableStateOf<ZoomableContent>( mutableStateOf<ZoomableContent>(
@@ -63,7 +63,7 @@ class ChannelMessageEvent(
} }
nip94attachments?.let { nip94attachments?.let {
it.forEach { it.forEach {
tags.add(arrayOf("nip94", it.toJson())) //tags.add(arrayOf("nip94", it.toJson()))
} }
} }
@@ -119,7 +119,13 @@ class ClassifiedsEvent(
tags.add(arrayOf("zap", it.lnAddressOrPubKeyHex, it.relay ?: "", it.weight.toString())) tags.add(arrayOf("zap", it.lnAddressOrPubKeyHex, it.relay ?: "", it.weight.toString()))
} }
findURLs(message).forEach { findURLs(message).forEach {
val removedParamsFromUrl = it.split("?")[0].lowercase() val removedParamsFromUrl = if (it.contains("?"))
it.split("?")[0].lowercase()
else if (it.contains("#"))
it.split("#")[0].lowercase()
else
it
if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) { if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) {
tags.add(arrayOf("image", it)) tags.add(arrayOf("image", it))
} }
@@ -136,7 +142,7 @@ class ClassifiedsEvent(
} }
nip94attachments?.let { nip94attachments?.let {
it.forEach { it.forEach {
tags.add(arrayOf("nip94", it.toJson())) //tags.add(arrayOf("nip94", it.toJson()))
} }
} }
@@ -83,7 +83,7 @@ class LiveActivitiesChatMessageEvent(
} }
nip94attachments?.let { nip94attachments?.let {
it.forEach { it.forEach {
tags.add(arrayOf("nip94", it.toJson())) //tags.add(arrayOf("nip94", it.toJson()))
} }
} }
@@ -102,7 +102,7 @@ class PollNoteEvent(
} }
nip94attachments?.let { nip94attachments?.let {
it.forEach { it.forEach {
tags.add(arrayOf("nip94", it.toJson())) //tags.add(arrayOf("nip94", it.toJson()))
} }
} }
@@ -99,7 +99,7 @@ class TextNoteEvent(
} }
nip94attachments?.let { nip94attachments?.let {
it.forEach { it.forEach {
tags.add(arrayOf("nip94", it.toJson())) //tags.add(arrayOf("nip94", it.toJson()))
} }
} }