Support for NIP-94
This commit is contained in:
@@ -146,6 +146,9 @@ dependencies {
|
|||||||
// view svgs
|
// view svgs
|
||||||
implementation "io.coil-kt:coil-svg:$coil_version"
|
implementation "io.coil-kt:coil-svg:$coil_version"
|
||||||
|
|
||||||
|
// create blurhash
|
||||||
|
implementation group: 'io.trbl', name: 'blurhash', version: '1.0.0'
|
||||||
|
|
||||||
// Rendering clickable text
|
// Rendering clickable text
|
||||||
implementation "com.google.accompanist:accompanist-flowlayout:$accompanist_version"
|
implementation "com.google.accompanist:accompanist-flowlayout:$accompanist_version"
|
||||||
// Permission to upload pictures:
|
// Permission to upload pictures:
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.model
|
|||||||
import android.content.res.Resources
|
import android.content.res.Resources
|
||||||
import androidx.core.os.ConfigurationCompat
|
import androidx.core.os.ConfigurationCompat
|
||||||
import androidx.lifecycle.LiveData
|
import androidx.lifecycle.LiveData
|
||||||
|
import com.vitorpamplona.amethyst.service.FileHeader
|
||||||
import com.vitorpamplona.amethyst.service.model.*
|
import com.vitorpamplona.amethyst.service.model.*
|
||||||
import com.vitorpamplona.amethyst.service.relays.Client
|
import com.vitorpamplona.amethyst.service.relays.Client
|
||||||
import com.vitorpamplona.amethyst.service.relays.Constants
|
import com.vitorpamplona.amethyst.service.relays.Constants
|
||||||
@@ -357,6 +358,25 @@ class Account(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun sendHeader(headerInfo: FileHeader): Note? {
|
||||||
|
if (!isWriteable()) return null
|
||||||
|
|
||||||
|
val signedEvent = FileHeaderEvent.create(
|
||||||
|
url = headerInfo.url,
|
||||||
|
mimeType = headerInfo.mimeType,
|
||||||
|
hash = headerInfo.hash,
|
||||||
|
size = headerInfo.size.toString(),
|
||||||
|
blurhash = headerInfo.blurHash,
|
||||||
|
description = headerInfo.description,
|
||||||
|
privateKey = loggedIn.privKey!!
|
||||||
|
)
|
||||||
|
|
||||||
|
Client.send(signedEvent)
|
||||||
|
LocalCache.consume(signedEvent)
|
||||||
|
|
||||||
|
return LocalCache.notes[signedEvent.id]
|
||||||
|
}
|
||||||
|
|
||||||
fun sendPost(message: String, replyTo: List<Note>?, mentions: List<User>?, tags: List<String>? = null) {
|
fun sendPost(message: String, replyTo: List<Note>?, mentions: List<User>?, tags: List<String>? = null) {
|
||||||
if (!isWriteable()) return
|
if (!isWriteable()) return
|
||||||
|
|
||||||
|
|||||||
@@ -651,6 +651,19 @@ object LocalCache {
|
|||||||
refreshObservers(note)
|
refreshObservers(note)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun consume(event: FileHeaderEvent) {
|
||||||
|
val note = getOrCreateNote(event.id)
|
||||||
|
|
||||||
|
// Already processed this event.
|
||||||
|
if (note.event != null) return
|
||||||
|
|
||||||
|
val author = getOrCreateUser(event.pubKey)
|
||||||
|
|
||||||
|
note.loadEvent(event, author, emptyList())
|
||||||
|
|
||||||
|
refreshObservers(note)
|
||||||
|
}
|
||||||
|
|
||||||
fun findUsersStartingWith(username: String): List<User> {
|
fun findUsersStartingWith(username: String): List<User> {
|
||||||
return users.values.filter {
|
return users.values.filter {
|
||||||
(it.anyNameStartsWith(username)) ||
|
(it.anyNameStartsWith(username)) ||
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
package com.vitorpamplona.amethyst.service
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.Color
|
||||||
|
import kotlin.math.cos
|
||||||
|
import kotlin.math.pow
|
||||||
|
import kotlin.math.withSign
|
||||||
|
|
||||||
|
object BlurHashDecoder {
|
||||||
|
|
||||||
|
// cache Math.cos() calculations to improve performance.
|
||||||
|
// The number of calculations can be huge for many bitmaps: width * height * numCompX * numCompY * 2 * nBitmaps
|
||||||
|
// the cache is enabled by default, it is recommended to disable it only when just a few images are displayed
|
||||||
|
private val cacheCosinesX = HashMap<Int, DoubleArray>()
|
||||||
|
private val cacheCosinesY = HashMap<Int, DoubleArray>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear calculations stored in memory cache.
|
||||||
|
* The cache is not big, but will increase when many image sizes are used,
|
||||||
|
* if the app needs memory it is recommended to clear it.
|
||||||
|
*/
|
||||||
|
fun clearCache() {
|
||||||
|
cacheCosinesX.clear()
|
||||||
|
cacheCosinesY.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns width/height
|
||||||
|
*/
|
||||||
|
fun aspectRatio(blurHash: String?): Float? {
|
||||||
|
if (blurHash == null || blurHash.length < 6) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
val numCompEnc = decode83(blurHash, 0, 1)
|
||||||
|
val numCompX = (numCompEnc % 9) + 1
|
||||||
|
val numCompY = (numCompEnc / 9) + 1
|
||||||
|
if (blurHash.length != 4 + 2 * numCompX * numCompY) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return numCompX.toFloat() / numCompY.toFloat()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode a blur hash into a new bitmap.
|
||||||
|
*
|
||||||
|
* @param useCache use in memory cache for the calculated math, reused by images with same size.
|
||||||
|
* if the cache does not exist yet it will be created and populated with new calculations.
|
||||||
|
* By default it is true.
|
||||||
|
*/
|
||||||
|
fun decode(blurHash: String?, width: Int, height: Int, punch: Float = 1f, useCache: Boolean = true): Bitmap? {
|
||||||
|
if (blurHash == null || blurHash.length < 6) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
val numCompEnc = decode83(blurHash, 0, 1)
|
||||||
|
val numCompX = (numCompEnc % 9) + 1
|
||||||
|
val numCompY = (numCompEnc / 9) + 1
|
||||||
|
if (blurHash.length != 4 + 2 * numCompX * numCompY) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
val maxAcEnc = decode83(blurHash, 1, 2)
|
||||||
|
val maxAc = (maxAcEnc + 1) / 166f
|
||||||
|
val colors = Array(numCompX * numCompY) { i ->
|
||||||
|
if (i == 0) {
|
||||||
|
val colorEnc = decode83(blurHash, 2, 6)
|
||||||
|
decodeDc(colorEnc)
|
||||||
|
} else {
|
||||||
|
val from = 4 + i * 2
|
||||||
|
val colorEnc = decode83(blurHash, from, from + 2)
|
||||||
|
decodeAc(colorEnc, maxAc * punch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return composeBitmap(width, height, numCompX, numCompY, colors, useCache)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun decode83(str: String, from: Int = 0, to: Int = str.length): Int {
|
||||||
|
var result = 0
|
||||||
|
for (i in from until to) {
|
||||||
|
val index = charMap[str[i]] ?: -1
|
||||||
|
if (index != -1) {
|
||||||
|
result = result * 83 + index
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun decodeDc(colorEnc: Int): FloatArray {
|
||||||
|
val r = colorEnc shr 16
|
||||||
|
val g = (colorEnc shr 8) and 255
|
||||||
|
val b = colorEnc and 255
|
||||||
|
return floatArrayOf(srgbToLinear(r), srgbToLinear(g), srgbToLinear(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun srgbToLinear(colorEnc: Int): Float {
|
||||||
|
val v = colorEnc / 255f
|
||||||
|
return if (v <= 0.04045f) {
|
||||||
|
(v / 12.92f)
|
||||||
|
} else {
|
||||||
|
((v + 0.055f) / 1.055f).pow(2.4f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun decodeAc(value: Int, maxAc: Float): FloatArray {
|
||||||
|
val r = value / (19 * 19)
|
||||||
|
val g = (value / 19) % 19
|
||||||
|
val b = value % 19
|
||||||
|
return floatArrayOf(
|
||||||
|
signedPow2((r - 9) / 9.0f) * maxAc,
|
||||||
|
signedPow2((g - 9) / 9.0f) * maxAc,
|
||||||
|
signedPow2((b - 9) / 9.0f) * maxAc
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun signedPow2(value: Float) = value.pow(2f).withSign(value)
|
||||||
|
|
||||||
|
private fun composeBitmap(
|
||||||
|
width: Int,
|
||||||
|
height: Int,
|
||||||
|
numCompX: Int,
|
||||||
|
numCompY: Int,
|
||||||
|
colors: Array<FloatArray>,
|
||||||
|
useCache: Boolean
|
||||||
|
): Bitmap {
|
||||||
|
// use an array for better performance when writing pixel colors
|
||||||
|
val imageArray = IntArray(width * height)
|
||||||
|
val calculateCosX = !useCache || !cacheCosinesX.containsKey(width * numCompX)
|
||||||
|
val cosinesX = getArrayForCosinesX(calculateCosX, width, numCompX)
|
||||||
|
val calculateCosY = !useCache || !cacheCosinesY.containsKey(height * numCompY)
|
||||||
|
val cosinesY = getArrayForCosinesY(calculateCosY, height, numCompY)
|
||||||
|
for (y in 0 until height) {
|
||||||
|
for (x in 0 until width) {
|
||||||
|
var r = 0f
|
||||||
|
var g = 0f
|
||||||
|
var b = 0f
|
||||||
|
for (j in 0 until numCompY) {
|
||||||
|
for (i in 0 until numCompX) {
|
||||||
|
val cosX = cosinesX.getCos(calculateCosX, i, numCompX, x, width)
|
||||||
|
val cosY = cosinesY.getCos(calculateCosY, j, numCompY, y, height)
|
||||||
|
val basis = (cosX * cosY).toFloat()
|
||||||
|
val color = colors[j * numCompX + i]
|
||||||
|
r += color[0] * basis
|
||||||
|
g += color[1] * basis
|
||||||
|
b += color[2] * basis
|
||||||
|
}
|
||||||
|
}
|
||||||
|
imageArray[x + width * y] = Color.rgb(linearToSrgb(r), linearToSrgb(g), linearToSrgb(b))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Bitmap.createBitmap(imageArray, width, height, Bitmap.Config.ARGB_8888)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getArrayForCosinesY(calculate: Boolean, height: Int, numCompY: Int) = when {
|
||||||
|
calculate -> {
|
||||||
|
DoubleArray(height * numCompY).also {
|
||||||
|
cacheCosinesY[height * numCompY] = it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
cacheCosinesY[height * numCompY]!!
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getArrayForCosinesX(calculate: Boolean, width: Int, numCompX: Int) = when {
|
||||||
|
calculate -> {
|
||||||
|
DoubleArray(width * numCompX).also {
|
||||||
|
cacheCosinesX[width * numCompX] = it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> cacheCosinesX[width * numCompX]!!
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun DoubleArray.getCos(
|
||||||
|
calculate: Boolean,
|
||||||
|
x: Int,
|
||||||
|
numComp: Int,
|
||||||
|
y: Int,
|
||||||
|
size: Int
|
||||||
|
): Double {
|
||||||
|
if (calculate) {
|
||||||
|
this[x + numComp * y] = cos(Math.PI * y * x / size)
|
||||||
|
}
|
||||||
|
return this[x + numComp * y]
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun linearToSrgb(value: Float): Int {
|
||||||
|
val v = value.coerceIn(0f, 1f)
|
||||||
|
return if (v <= 0.0031308f) {
|
||||||
|
(v * 12.92f * 255f + 0.5f).toInt()
|
||||||
|
} else {
|
||||||
|
((1.055f * v.pow(1 / 2.4f) - 0.055f) * 255 + 0.5f).toInt()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val charMap = listOf(
|
||||||
|
'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F', 'G',
|
||||||
|
'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X',
|
||||||
|
'Y', 'Z', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o',
|
||||||
|
'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', '#', '$', '%', '*', '+', ',',
|
||||||
|
'-', '.', ':', ';', '=', '?', '@', '[', ']', '^', '_', '{', '|', '}', '~'
|
||||||
|
)
|
||||||
|
.mapIndexed { i, c -> c to i }
|
||||||
|
.toMap()
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package com.vitorpamplona.amethyst.service
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.core.graphics.drawable.toDrawable
|
||||||
|
import coil.ImageLoader
|
||||||
|
import coil.decode.DataSource
|
||||||
|
import coil.fetch.DrawableResult
|
||||||
|
import coil.fetch.FetchResult
|
||||||
|
import coil.fetch.Fetcher
|
||||||
|
import coil.request.ImageRequest
|
||||||
|
import coil.request.Options
|
||||||
|
import java.net.URLDecoder
|
||||||
|
import java.net.URLEncoder
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
class BlurHashFetcher(
|
||||||
|
private val options: Options,
|
||||||
|
private val data: Uri
|
||||||
|
) : Fetcher {
|
||||||
|
|
||||||
|
override suspend fun fetch(): FetchResult {
|
||||||
|
val encodedHash = data.toString().removePrefix("bluehash:")
|
||||||
|
val hash = URLDecoder.decode(encodedHash, "utf-8")
|
||||||
|
|
||||||
|
val aspectRatio = BlurHashDecoder.aspectRatio(hash) ?: 1.0f
|
||||||
|
|
||||||
|
val preferredWidth = 100
|
||||||
|
|
||||||
|
val bitmap = BlurHashDecoder.decode(
|
||||||
|
hash,
|
||||||
|
preferredWidth,
|
||||||
|
(preferredWidth * (1 / aspectRatio)).roundToInt()
|
||||||
|
)
|
||||||
|
|
||||||
|
if (bitmap == null) {
|
||||||
|
throw Exception("Unable to convert Bluehash $hash")
|
||||||
|
}
|
||||||
|
|
||||||
|
return DrawableResult(
|
||||||
|
drawable = bitmap.toDrawable(options.context.resources),
|
||||||
|
isSampled = false,
|
||||||
|
dataSource = DataSource.MEMORY
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
object Factory : Fetcher.Factory<Uri> {
|
||||||
|
override fun create(data: Uri, options: Options, imageLoader: ImageLoader): Fetcher {
|
||||||
|
return BlurHashFetcher(options, data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
object BlurHashRequester {
|
||||||
|
fun imageRequest(context: Context, message: String): ImageRequest {
|
||||||
|
val encodedMessage = URLEncoder.encode(message, "utf-8")
|
||||||
|
|
||||||
|
return ImageRequest
|
||||||
|
.Builder(context)
|
||||||
|
.data("bluehash:$encodedMessage")
|
||||||
|
.fetcherFactory(BlurHashFetcher.Factory)
|
||||||
|
.crossfade(100)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package com.vitorpamplona.amethyst.service
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import android.util.Log
|
||||||
|
import com.vitorpamplona.amethyst.model.toHexKey
|
||||||
|
import io.trbl.blurhash.BlurHash
|
||||||
|
import java.net.URL
|
||||||
|
import java.security.MessageDigest
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
class FileHeader(
|
||||||
|
val url: String,
|
||||||
|
val mimeType: String?,
|
||||||
|
val hash: String,
|
||||||
|
val size: Int,
|
||||||
|
val blurHash: String?,
|
||||||
|
val description: String? = null
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
fun prepare(fileUrl: String, mimeType: String?, onReady: (FileHeader) -> Unit, onError: () -> Unit) {
|
||||||
|
try {
|
||||||
|
val imageData = URL(fileUrl).readBytes()
|
||||||
|
val sha256 = MessageDigest.getInstance("SHA-256")
|
||||||
|
|
||||||
|
val hash = sha256.digest(imageData).toHexKey()
|
||||||
|
val size = imageData.size
|
||||||
|
|
||||||
|
val blurHash = if (mimeType?.startsWith("image/") == true) {
|
||||||
|
val opt = BitmapFactory.Options()
|
||||||
|
opt.inPreferredConfig = Bitmap.Config.ARGB_8888
|
||||||
|
val mBitmap = BitmapFactory.decodeByteArray(imageData, 0, imageData.size, opt)
|
||||||
|
|
||||||
|
val intArray = IntArray(mBitmap.width * mBitmap.height)
|
||||||
|
mBitmap.getPixels(
|
||||||
|
intArray,
|
||||||
|
0,
|
||||||
|
mBitmap.width,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
mBitmap.width,
|
||||||
|
mBitmap.height
|
||||||
|
)
|
||||||
|
|
||||||
|
val aspectRatio = (mBitmap.width).toFloat() / (mBitmap.height).toFloat()
|
||||||
|
|
||||||
|
if (aspectRatio > 1) {
|
||||||
|
BlurHash.encode(intArray, mBitmap.width, mBitmap.height, 9, (9 * (1 / aspectRatio)).roundToInt())
|
||||||
|
} else if (aspectRatio < 1) {
|
||||||
|
BlurHash.encode(intArray, mBitmap.width, mBitmap.height, (9 * aspectRatio).roundToInt(), 9)
|
||||||
|
} else {
|
||||||
|
BlurHash.encode(intArray, mBitmap.width, mBitmap.height, 4, 4)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
onReady(FileHeader(fileUrl, mimeType, hash, size, blurHash, ""))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("ImageDownload", "Couldn't convert image in to File Header: ${e.message}")
|
||||||
|
onError()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -75,6 +75,7 @@ abstract class NostrDataSource(val debugName: String) {
|
|||||||
is ContactListEvent -> LocalCache.consume(event)
|
is ContactListEvent -> LocalCache.consume(event)
|
||||||
is DeletionEvent -> LocalCache.consume(event)
|
is DeletionEvent -> LocalCache.consume(event)
|
||||||
|
|
||||||
|
is FileHeaderEvent -> LocalCache.consume(event)
|
||||||
is LnZapEvent -> {
|
is LnZapEvent -> {
|
||||||
event.zapRequest?.let { onEvent(it, subscriptionId, relay) }
|
event.zapRequest?.let { onEvent(it, subscriptionId, relay) }
|
||||||
LocalCache.consume(event)
|
LocalCache.consume(event)
|
||||||
|
|||||||
@@ -111,9 +111,12 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
|
|||||||
types = FeedType.values().toSet(),
|
types = FeedType.values().toSet(),
|
||||||
filter = JsonFilter(
|
filter = JsonFilter(
|
||||||
kinds = listOf(
|
kinds = listOf(
|
||||||
TextNoteEvent.kind, LongTextNoteEvent.kind, ReactionEvent.kind, RepostEvent.kind, LnZapEvent.kind, LnZapRequestEvent.kind,
|
TextNoteEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind,
|
||||||
ChannelMessageEvent.kind, ChannelCreateEvent.kind, ChannelMetadataEvent.kind, BadgeDefinitionEvent.kind, BadgeAwardEvent.kind, BadgeProfilesEvent.kind,
|
ReactionEvent.kind, RepostEvent.kind,
|
||||||
PollNoteEvent.kind, PrivateDmEvent.kind
|
LnZapEvent.kind, LnZapRequestEvent.kind,
|
||||||
|
ChannelMessageEvent.kind, ChannelCreateEvent.kind, ChannelMetadataEvent.kind,
|
||||||
|
BadgeDefinitionEvent.kind, BadgeAwardEvent.kind, BadgeProfilesEvent.kind,
|
||||||
|
PrivateDmEvent.kind, FileHeaderEvent.kind
|
||||||
),
|
),
|
||||||
ids = interestedEvents.toList()
|
ids = interestedEvents.toList()
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -222,6 +222,7 @@ open class Event(
|
|||||||
ContactListEvent.kind -> ContactListEvent(id, pubKey, createdAt, tags, content, sig)
|
ContactListEvent.kind -> ContactListEvent(id, pubKey, createdAt, tags, content, sig)
|
||||||
DeletionEvent.kind -> DeletionEvent(id, pubKey, createdAt, tags, content, sig)
|
DeletionEvent.kind -> DeletionEvent(id, pubKey, createdAt, tags, content, sig)
|
||||||
|
|
||||||
|
FileHeaderEvent.kind -> FileHeaderEvent(id, pubKey, createdAt, tags, content, sig)
|
||||||
LnZapEvent.kind -> LnZapEvent(id, pubKey, createdAt, tags, content, sig)
|
LnZapEvent.kind -> LnZapEvent(id, pubKey, createdAt, tags, content, sig)
|
||||||
LnZapPaymentRequestEvent.kind -> LnZapPaymentRequestEvent(id, pubKey, createdAt, tags, content, sig)
|
LnZapPaymentRequestEvent.kind -> LnZapPaymentRequestEvent(id, pubKey, createdAt, tags, content, sig)
|
||||||
LnZapRequestEvent.kind -> LnZapRequestEvent(id, pubKey, createdAt, tags, content, sig)
|
LnZapRequestEvent.kind -> LnZapRequestEvent(id, pubKey, createdAt, tags, content, sig)
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package com.vitorpamplona.amethyst.service.model
|
||||||
|
|
||||||
|
import com.vitorpamplona.amethyst.model.HexKey
|
||||||
|
import com.vitorpamplona.amethyst.model.toHexKey
|
||||||
|
import nostr.postr.Utils
|
||||||
|
import java.util.Date
|
||||||
|
|
||||||
|
class FileHeaderEvent(
|
||||||
|
id: HexKey,
|
||||||
|
pubKey: HexKey,
|
||||||
|
createdAt: Long,
|
||||||
|
tags: List<List<String>>,
|
||||||
|
content: String,
|
||||||
|
sig: HexKey
|
||||||
|
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||||
|
|
||||||
|
fun url() = tags.firstOrNull { it.size > 1 && it[0] == URL }?.get(1)
|
||||||
|
fun encryptionKey() = tags.firstOrNull { it.size > 2 && it[0] == ENCRYPTION_KEY }?.let { AESGCM(it[1], it[2]) }
|
||||||
|
fun mimeType() = tags.firstOrNull { it.size > 1 && it[0] == MIME_TYPE }?.get(1)
|
||||||
|
fun hash() = tags.firstOrNull { it.size > 1 && it[0] == HASH }?.get(1)
|
||||||
|
fun size() = tags.firstOrNull { it.size > 1 && it[0] == FILE_SIZE }?.get(1)
|
||||||
|
fun magnetURI() = tags.firstOrNull { it.size > 1 && it[0] == MAGNET_URI }?.get(1)
|
||||||
|
fun torrentInfoHash() = tags.firstOrNull { it.size > 1 && it[0] == TORRENT_INFOHASH }?.get(1)
|
||||||
|
fun blurhash() = tags.firstOrNull { it.size > 1 && it[0] == BLUR_HASH }?.get(1)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val kind = 1063
|
||||||
|
|
||||||
|
private const val URL = "url"
|
||||||
|
private const val ENCRYPTION_KEY = "aes-256-gcm"
|
||||||
|
private const val MIME_TYPE = "m"
|
||||||
|
private const val FILE_SIZE = "size"
|
||||||
|
private const val HASH = "x"
|
||||||
|
private const val MAGNET_URI = "magnet"
|
||||||
|
private const val TORRENT_INFOHASH = "i"
|
||||||
|
private const val BLUR_HASH = "blurhash"
|
||||||
|
|
||||||
|
fun create(
|
||||||
|
url: String,
|
||||||
|
mimeType: String? = null,
|
||||||
|
description: String? = null,
|
||||||
|
hash: String? = null,
|
||||||
|
size: String? = null,
|
||||||
|
blurhash: String? = null,
|
||||||
|
magnetURI: String? = null,
|
||||||
|
torrentInfoHash: String? = null,
|
||||||
|
encryptionKey: AESGCM? = null,
|
||||||
|
privateKey: ByteArray,
|
||||||
|
createdAt: Long = Date().time / 1000
|
||||||
|
): FileHeaderEvent {
|
||||||
|
var tags = listOfNotNull(
|
||||||
|
listOf(URL, url),
|
||||||
|
mimeType?.let { listOf(MIME_TYPE, mimeType) },
|
||||||
|
hash?.let { listOf(HASH, it) },
|
||||||
|
size?.let { listOf(FILE_SIZE, it) },
|
||||||
|
blurhash?.let { listOf(BLUR_HASH, it) },
|
||||||
|
magnetURI?.let { listOf(MAGNET_URI, it) },
|
||||||
|
torrentInfoHash?.let { listOf(TORRENT_INFOHASH, it) },
|
||||||
|
encryptionKey?.let { listOf(ENCRYPTION_KEY, it.key, it.nonce) }
|
||||||
|
)
|
||||||
|
|
||||||
|
val content = description ?: ""
|
||||||
|
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||||
|
val id = generateId(pubKey, createdAt, kind, tags, content)
|
||||||
|
val sig = Utils.sign(id, privateKey)
|
||||||
|
return FileHeaderEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class AESGCM(val key: String, val nonce: String)
|
||||||
@@ -15,7 +15,7 @@ object ImageUploader {
|
|||||||
fun uploadImage(
|
fun uploadImage(
|
||||||
uri: Uri,
|
uri: Uri,
|
||||||
contentResolver: ContentResolver,
|
contentResolver: ContentResolver,
|
||||||
onSuccess: (String) -> Unit,
|
onSuccess: (String, String?) -> Unit,
|
||||||
onError: (Throwable) -> Unit
|
onError: (Throwable) -> Unit
|
||||||
) {
|
) {
|
||||||
val contentType = contentResolver.getType(uri)
|
val contentType = contentResolver.getType(uri)
|
||||||
@@ -64,7 +64,7 @@ object ImageUploader {
|
|||||||
"There must be an uploaded image URL in the response"
|
"There must be an uploaded image URL in the response"
|
||||||
}
|
}
|
||||||
|
|
||||||
onSuccess(url)
|
onSuccess(url, contentType)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
e.printStackTrace()
|
e.printStackTrace()
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import androidx.compose.ui.text.input.TextFieldValue
|
|||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.vitorpamplona.amethyst.model.*
|
import com.vitorpamplona.amethyst.model.*
|
||||||
|
import com.vitorpamplona.amethyst.service.FileHeader
|
||||||
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
|
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
|
||||||
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
||||||
import com.vitorpamplona.amethyst.ui.components.isValidURL
|
import com.vitorpamplona.amethyst.ui.components.isValidURL
|
||||||
@@ -110,13 +111,39 @@ open class NewPostViewModel : ViewModel() {
|
|||||||
ImageUploader.uploadImage(
|
ImageUploader.uploadImage(
|
||||||
uri = it,
|
uri = it,
|
||||||
contentResolver = context.contentResolver,
|
contentResolver = context.contentResolver,
|
||||||
onSuccess = { imageUrl ->
|
onSuccess = { imageUrl, mimeType ->
|
||||||
isUploadingImage = false
|
|
||||||
message = TextFieldValue(message.text + "\n\n" + imageUrl)
|
|
||||||
|
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
delay(2000)
|
// Images don't seem to be ready immediately after upload
|
||||||
urlPreview = findUrlInMessage()
|
|
||||||
|
if (mimeType?.startsWith("image/") == true) {
|
||||||
|
delay(2000)
|
||||||
|
} else {
|
||||||
|
delay(5000)
|
||||||
|
}
|
||||||
|
|
||||||
|
FileHeader.prepare(
|
||||||
|
imageUrl,
|
||||||
|
mimeType,
|
||||||
|
onReady = {
|
||||||
|
val note = account?.sendHeader(it)
|
||||||
|
|
||||||
|
isUploadingImage = false
|
||||||
|
|
||||||
|
if (note == null) {
|
||||||
|
message = TextFieldValue(message.text + "\n\n" + imageUrl)
|
||||||
|
} else {
|
||||||
|
message = TextFieldValue(message.text + "\n\nnostr:" + note.idNote())
|
||||||
|
}
|
||||||
|
|
||||||
|
urlPreview = findUrlInMessage()
|
||||||
|
},
|
||||||
|
onError = {
|
||||||
|
isUploadingImage = false
|
||||||
|
viewModelScope.launch {
|
||||||
|
imageUploadingError.emit("Failed to upload the image / video")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError = {
|
onError = {
|
||||||
|
|||||||
@@ -171,7 +171,7 @@ class NewUserMetadataViewModel : ViewModel() {
|
|||||||
ImageUploader.uploadImage(
|
ImageUploader.uploadImage(
|
||||||
uri = it,
|
uri = it,
|
||||||
contentResolver = context.contentResolver,
|
contentResolver = context.contentResolver,
|
||||||
onSuccess = { imageUrl ->
|
onSuccess = { imageUrl, mimeType ->
|
||||||
onUploading(false)
|
onUploading(false)
|
||||||
onUploaded(imageUrl)
|
onUploaded(imageUrl)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -139,11 +139,17 @@ fun RichTextViewer(
|
|||||||
} else {
|
} else {
|
||||||
val urls = UrlDetector(content, UrlDetectorOptions.Default).detect()
|
val urls = UrlDetector(content, UrlDetectorOptions.Default).detect()
|
||||||
val urlSet = urls.mapTo(LinkedHashSet(urls.size)) { it.originalUrl }
|
val urlSet = urls.mapTo(LinkedHashSet(urls.size)) { it.originalUrl }
|
||||||
val imagesForPager = urlSet.filter { fullUrl ->
|
val imagesForPager = urlSet.mapNotNull { fullUrl ->
|
||||||
val removedParamsFromUrl = fullUrl.split("?")[0].lowercase()
|
val removedParamsFromUrl = fullUrl.split("?")[0].lowercase()
|
||||||
imageExtensions.any { removedParamsFromUrl.endsWith(it) } || videoExtensions.any { removedParamsFromUrl.endsWith(it) }
|
if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) {
|
||||||
}
|
ZoomableImage(fullUrl)
|
||||||
val imagesForPagerSet = imagesForPager.toSet()
|
} else if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) {
|
||||||
|
ZoomableVideo(fullUrl)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}.associateBy { it.url }
|
||||||
|
val imageList = imagesForPager.values.toList()
|
||||||
|
|
||||||
// FlowRow doesn't work well with paragraphs. So we need to split them
|
// FlowRow doesn't work well with paragraphs. So we need to split them
|
||||||
content.split('\n').forEach { paragraph ->
|
content.split('\n').forEach { paragraph ->
|
||||||
@@ -152,8 +158,9 @@ fun RichTextViewer(
|
|||||||
s.forEach { word: String ->
|
s.forEach { word: String ->
|
||||||
if (canPreview) {
|
if (canPreview) {
|
||||||
// Explicit URL
|
// Explicit URL
|
||||||
if (imagesForPagerSet.contains(word)) {
|
val img = imagesForPager[word]
|
||||||
ZoomableImageView(word, imagesForPager)
|
if (img != null) {
|
||||||
|
ZoomableContentView(img, imageList)
|
||||||
} else if (urlSet.contains(word)) {
|
} else if (urlSet.contains(word)) {
|
||||||
UrlPreview(word, "$word ")
|
UrlPreview(word, "$word ")
|
||||||
} else if (word.startsWith("lnbc", true)) {
|
} else if (word.startsWith("lnbc", true)) {
|
||||||
|
|||||||
@@ -0,0 +1,408 @@
|
|||||||
|
package com.vitorpamplona.amethyst.ui.components
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.Log
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.combinedClickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.pager.PagerState
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.InlineTextContent
|
||||||
|
import androidx.compose.foundation.text.appendInlineContent
|
||||||
|
import androidx.compose.material.Icon
|
||||||
|
import androidx.compose.material.IconButton
|
||||||
|
import androidx.compose.material.LocalTextStyle
|
||||||
|
import androidx.compose.material.MaterialTheme
|
||||||
|
import androidx.compose.material.Surface
|
||||||
|
import androidx.compose.material.Text
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Report
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
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
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.compositeOver
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalClipboardManager
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
import androidx.compose.ui.text.Placeholder
|
||||||
|
import androidx.compose.ui.text.PlaceholderVerticalAlign
|
||||||
|
import androidx.compose.ui.text.buildAnnotatedString
|
||||||
|
import androidx.compose.ui.text.withStyle
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.compose.ui.window.Dialog
|
||||||
|
import androidx.compose.ui.window.DialogProperties
|
||||||
|
import coil.annotation.ExperimentalCoilApi
|
||||||
|
import coil.compose.AsyncImage
|
||||||
|
import coil.compose.AsyncImagePainter
|
||||||
|
import coil.imageLoader
|
||||||
|
import com.vitorpamplona.amethyst.R
|
||||||
|
import com.vitorpamplona.amethyst.model.toHexKey
|
||||||
|
import com.vitorpamplona.amethyst.service.BlurHashRequester
|
||||||
|
import com.vitorpamplona.amethyst.ui.actions.CloseButton
|
||||||
|
import com.vitorpamplona.amethyst.ui.actions.LoadingAnimation
|
||||||
|
import com.vitorpamplona.amethyst.ui.actions.SaveToGallery
|
||||||
|
import com.vitorpamplona.amethyst.ui.theme.Nip05
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import net.engawapg.lib.zoomable.rememberZoomState
|
||||||
|
import net.engawapg.lib.zoomable.zoomable
|
||||||
|
import java.security.MessageDigest
|
||||||
|
|
||||||
|
abstract class ZoomableContent(
|
||||||
|
val url: String,
|
||||||
|
val description: String? = null,
|
||||||
|
val hash: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
class ZoomableImage(
|
||||||
|
url: String,
|
||||||
|
description: String? = null,
|
||||||
|
hash: String? = null,
|
||||||
|
val bluehash: String? = null
|
||||||
|
) : ZoomableContent(url, description, hash)
|
||||||
|
|
||||||
|
class ZoomableVideo(
|
||||||
|
url: String,
|
||||||
|
description: String? = null,
|
||||||
|
hash: String? = null
|
||||||
|
) : ZoomableContent(url, description, hash)
|
||||||
|
|
||||||
|
fun figureOutMimeType(fullUrl: String): ZoomableContent {
|
||||||
|
val removedParamsFromUrl = fullUrl.split("?")[0].lowercase()
|
||||||
|
val isImage = imageExtensions.any { removedParamsFromUrl.endsWith(it) }
|
||||||
|
val isVideo = videoExtensions.any { removedParamsFromUrl.endsWith(it) }
|
||||||
|
|
||||||
|
return if (isImage) {
|
||||||
|
ZoomableImage(fullUrl)
|
||||||
|
} else if (isVideo) {
|
||||||
|
ZoomableVideo(fullUrl)
|
||||||
|
} else {
|
||||||
|
ZoomableImage(fullUrl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
|
fun ZoomableContentView(content: ZoomableContent, images: List<ZoomableContent> = listOf(content)) {
|
||||||
|
val clipboardManager = LocalClipboardManager.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
// store the dialog open or close state
|
||||||
|
var dialogOpen by remember {
|
||||||
|
mutableStateOf(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// store the dialog open or close state
|
||||||
|
var imageState by remember {
|
||||||
|
mutableStateOf<AsyncImagePainter.State?>(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
var verifiedHash by remember {
|
||||||
|
mutableStateOf<Boolean?>(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(key1 = content.url, key2 = imageState) {
|
||||||
|
if (imageState is AsyncImagePainter.State.Success) {
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
|
verifiedHash = verifyHash(content, context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val mainImageModifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(shape = RoundedCornerShape(15.dp))
|
||||||
|
.border(
|
||||||
|
1.dp,
|
||||||
|
MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
|
||||||
|
RoundedCornerShape(15.dp)
|
||||||
|
)
|
||||||
|
.combinedClickable(
|
||||||
|
onClick = { dialogOpen = true },
|
||||||
|
onLongClick = { clipboardManager.setText(AnnotatedString(content.url)) }
|
||||||
|
)
|
||||||
|
|
||||||
|
if (content is ZoomableImage) {
|
||||||
|
Box() {
|
||||||
|
AsyncImage(
|
||||||
|
model = content.url,
|
||||||
|
contentDescription = content.description,
|
||||||
|
contentScale = ContentScale.FillWidth,
|
||||||
|
modifier = mainImageModifier,
|
||||||
|
onLoading = {
|
||||||
|
imageState = it
|
||||||
|
},
|
||||||
|
onSuccess = {
|
||||||
|
imageState = it
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if (imageState !is AsyncImagePainter.State.Success) {
|
||||||
|
if (content.bluehash != null) {
|
||||||
|
DisplayBlueHash(content, mainImageModifier)
|
||||||
|
} else {
|
||||||
|
DisplayUrlWithLoadingSymbol(content)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
HashVerificationSymbol(verifiedHash, Modifier.align(Alignment.TopEnd))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
VideoView(content.url) { dialogOpen = true }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dialogOpen) {
|
||||||
|
ZoomableImageDialog(content, images, onDismiss = { dialogOpen = false })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DisplayUrlWithLoadingSymbol(content: ZoomableContent) {
|
||||||
|
ClickableUrl(urlText = "$content ", url = content.url)
|
||||||
|
|
||||||
|
val myId = "inlineContent"
|
||||||
|
val emptytext = buildAnnotatedString {
|
||||||
|
withStyle(
|
||||||
|
LocalTextStyle.current.copy(color = MaterialTheme.colors.primary).toSpanStyle()
|
||||||
|
) {
|
||||||
|
append("")
|
||||||
|
appendInlineContent(myId, "[icon]")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val inlineContent = mapOf(
|
||||||
|
Pair(
|
||||||
|
myId,
|
||||||
|
InlineTextContent(
|
||||||
|
Placeholder(
|
||||||
|
width = 17.sp,
|
||||||
|
height = 17.sp,
|
||||||
|
placeholderVerticalAlign = PlaceholderVerticalAlign.Center
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
LoadingAnimation()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Empty Text for Size of Icon
|
||||||
|
Text(
|
||||||
|
text = emptytext,
|
||||||
|
inlineContent = inlineContent
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DisplayBlueHash(
|
||||||
|
content: ZoomableImage,
|
||||||
|
modifier: Modifier
|
||||||
|
) {
|
||||||
|
if (content.bluehash == null) return
|
||||||
|
|
||||||
|
val context = LocalContext.current
|
||||||
|
AsyncImage(
|
||||||
|
model = BlurHashRequester.imageRequest(
|
||||||
|
context,
|
||||||
|
content.bluehash
|
||||||
|
),
|
||||||
|
contentDescription = content.description,
|
||||||
|
contentScale = ContentScale.FillWidth,
|
||||||
|
modifier = modifier
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
|
@Composable
|
||||||
|
fun ZoomableImageDialog(imageUrl: ZoomableContent, allImages: List<ZoomableContent> = listOf(imageUrl), onDismiss: () -> Unit) {
|
||||||
|
Dialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
properties = DialogProperties(usePlatformDefaultWidth = false)
|
||||||
|
) {
|
||||||
|
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colors.background) {
|
||||||
|
Column() {
|
||||||
|
val pagerState: PagerState = remember { PagerState() }
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(10.dp)
|
||||||
|
.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
CloseButton(onCancel = onDismiss)
|
||||||
|
|
||||||
|
SaveToGallery(url = allImages[pagerState.currentPage].url)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allImages.size > 1) {
|
||||||
|
SlidingCarousel(
|
||||||
|
pagerState = pagerState,
|
||||||
|
itemsCount = allImages.size,
|
||||||
|
itemContent = { index ->
|
||||||
|
RenderImageOrVideo(allImages[index])
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
RenderImageOrVideo(imageUrl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RenderImageOrVideo(content: ZoomableContent) {
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
// store the dialog open or close state
|
||||||
|
var imageState by remember {
|
||||||
|
mutableStateOf<AsyncImagePainter.State?>(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
var verifiedHash by remember {
|
||||||
|
mutableStateOf<Boolean?>(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(key1 = content.url, key2 = imageState) {
|
||||||
|
if (imageState is AsyncImagePainter.State.Success) {
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
|
verifiedHash = verifyHash(content, context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (content is ZoomableImage) {
|
||||||
|
Box() {
|
||||||
|
AsyncImage(
|
||||||
|
model = content.url,
|
||||||
|
contentDescription = content.description,
|
||||||
|
contentScale = ContentScale.FillWidth,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.zoomable(rememberZoomState()),
|
||||||
|
onLoading = {
|
||||||
|
imageState = it
|
||||||
|
},
|
||||||
|
onSuccess = {
|
||||||
|
imageState = it
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if (imageState !is AsyncImagePainter.State.Success) {
|
||||||
|
DisplayBlueHash(content = content, modifier = Modifier.fillMaxWidth())
|
||||||
|
} else {
|
||||||
|
HashVerificationSymbol(verifiedHash, Modifier.align(Alignment.TopEnd))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) {
|
||||||
|
VideoView(content.url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoilApi::class)
|
||||||
|
private suspend fun verifyHash(content: ZoomableContent, context: Context): Boolean? {
|
||||||
|
if (content.hash == null) return null
|
||||||
|
|
||||||
|
context.imageLoader.diskCache?.get(content.url)?.use { snapshot ->
|
||||||
|
val imageFile = snapshot.data.toFile()
|
||||||
|
val bytes = imageFile.readBytes()
|
||||||
|
val sha256 = MessageDigest.getInstance("SHA-256")
|
||||||
|
|
||||||
|
val hash = sha256.digest(bytes).toHexKey()
|
||||||
|
|
||||||
|
Log.d("Image Hash Verification", "$hash == ${content.hash}")
|
||||||
|
|
||||||
|
return hash == content.hash
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun HashVerificationSymbol(verifiedHash: Boolean?, modifier: Modifier) {
|
||||||
|
if (verifiedHash == null) return
|
||||||
|
|
||||||
|
val localContext = LocalContext.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier
|
||||||
|
.width(40.dp)
|
||||||
|
.height(40.dp)
|
||||||
|
.padding(10.dp)
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.clip(CircleShape)
|
||||||
|
.fillMaxSize(0.6f)
|
||||||
|
.align(Alignment.Center)
|
||||||
|
.background(MaterialTheme.colors.background)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (verifiedHash == true) {
|
||||||
|
IconButton(
|
||||||
|
onClick = {
|
||||||
|
scope.launch {
|
||||||
|
Toast.makeText(
|
||||||
|
localContext,
|
||||||
|
localContext.getString(R.string.hash_verification_passed),
|
||||||
|
Toast.LENGTH_LONG
|
||||||
|
).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(R.drawable.ic_verified),
|
||||||
|
"Hash Verified",
|
||||||
|
tint = Nip05.copy(0.52f).compositeOver(MaterialTheme.colors.background),
|
||||||
|
modifier = Modifier.size(30.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else if (verifiedHash == false) {
|
||||||
|
IconButton(
|
||||||
|
onClick = {
|
||||||
|
scope.launch {
|
||||||
|
Toast.makeText(
|
||||||
|
localContext,
|
||||||
|
localContext.getString(R.string.hash_verification_failed),
|
||||||
|
Toast.LENGTH_LONG
|
||||||
|
).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
tint = Color.Red,
|
||||||
|
imageVector = Icons.Default.Report,
|
||||||
|
contentDescription = "Invalid Hash",
|
||||||
|
modifier = Modifier.size(30.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,188 +0,0 @@
|
|||||||
package com.vitorpamplona.amethyst.ui.components
|
|
||||||
|
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
|
||||||
import androidx.compose.foundation.border
|
|
||||||
import androidx.compose.foundation.combinedClickable
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.pager.PagerState
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.foundation.text.InlineTextContent
|
|
||||||
import androidx.compose.foundation.text.appendInlineContent
|
|
||||||
import androidx.compose.material.LocalTextStyle
|
|
||||||
import androidx.compose.material.MaterialTheme
|
|
||||||
import androidx.compose.material.Surface
|
|
||||||
import androidx.compose.material.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.layout.ContentScale
|
|
||||||
import androidx.compose.ui.platform.LocalClipboardManager
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
|
||||||
import androidx.compose.ui.text.Placeholder
|
|
||||||
import androidx.compose.ui.text.PlaceholderVerticalAlign
|
|
||||||
import androidx.compose.ui.text.buildAnnotatedString
|
|
||||||
import androidx.compose.ui.text.withStyle
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import androidx.compose.ui.window.Dialog
|
|
||||||
import androidx.compose.ui.window.DialogProperties
|
|
||||||
import coil.compose.AsyncImage
|
|
||||||
import coil.compose.AsyncImagePainter
|
|
||||||
import com.vitorpamplona.amethyst.R
|
|
||||||
import com.vitorpamplona.amethyst.ui.actions.CloseButton
|
|
||||||
import com.vitorpamplona.amethyst.ui.actions.LoadingAnimation
|
|
||||||
import com.vitorpamplona.amethyst.ui.actions.SaveToGallery
|
|
||||||
import net.engawapg.lib.zoomable.rememberZoomState
|
|
||||||
import net.engawapg.lib.zoomable.zoomable
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
@OptIn(ExperimentalFoundationApi::class)
|
|
||||||
fun ZoomableImageView(word: String, images: List<String> = listOf(word)) {
|
|
||||||
val clipboardManager = LocalClipboardManager.current
|
|
||||||
|
|
||||||
// store the dialog open or close state
|
|
||||||
var dialogOpen by remember {
|
|
||||||
mutableStateOf(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
// store the dialog open or close state
|
|
||||||
var imageState by remember {
|
|
||||||
mutableStateOf<AsyncImagePainter.State?>(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
val removedParamsFromUrl = word.split("?")[0].lowercase()
|
|
||||||
if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) {
|
|
||||||
AsyncImage(
|
|
||||||
model = word,
|
|
||||||
contentDescription = word,
|
|
||||||
contentScale = ContentScale.FillWidth,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.clip(shape = RoundedCornerShape(15.dp))
|
|
||||||
.border(
|
|
||||||
1.dp,
|
|
||||||
MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
|
|
||||||
RoundedCornerShape(15.dp)
|
|
||||||
)
|
|
||||||
.combinedClickable(
|
|
||||||
onClick = { dialogOpen = true },
|
|
||||||
onLongClick = { clipboardManager.setText(AnnotatedString(word)) }
|
|
||||||
),
|
|
||||||
onLoading = {
|
|
||||||
imageState = it
|
|
||||||
},
|
|
||||||
onSuccess = {
|
|
||||||
imageState = it
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
if (imageState !is AsyncImagePainter.State.Success) {
|
|
||||||
ClickableUrl(urlText = "$word ", url = word)
|
|
||||||
|
|
||||||
val myId = "inlineContent"
|
|
||||||
val emptytext = buildAnnotatedString {
|
|
||||||
withStyle(
|
|
||||||
LocalTextStyle.current.copy(color = MaterialTheme.colors.primary).toSpanStyle()
|
|
||||||
) {
|
|
||||||
append("")
|
|
||||||
appendInlineContent(myId, "[icon]")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val inlineContent = mapOf(
|
|
||||||
Pair(
|
|
||||||
myId,
|
|
||||||
InlineTextContent(
|
|
||||||
Placeholder(
|
|
||||||
width = 17.sp,
|
|
||||||
height = 17.sp,
|
|
||||||
placeholderVerticalAlign = PlaceholderVerticalAlign.Center
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
LoadingAnimation()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
// Empty Text for Size of Icon
|
|
||||||
Text(
|
|
||||||
text = emptytext,
|
|
||||||
inlineContent = inlineContent
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
VideoView(word) { dialogOpen = true }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dialogOpen) {
|
|
||||||
ZoomableImageDialog(word, images, onDismiss = { dialogOpen = false })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalFoundationApi::class)
|
|
||||||
@Composable
|
|
||||||
fun ZoomableImageDialog(imageUrl: String, allImages: List<String> = listOf(imageUrl), onDismiss: () -> Unit) {
|
|
||||||
Dialog(
|
|
||||||
onDismissRequest = onDismiss,
|
|
||||||
properties = DialogProperties(usePlatformDefaultWidth = false)
|
|
||||||
) {
|
|
||||||
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colors.background) {
|
|
||||||
Column() {
|
|
||||||
var pagerState: PagerState = remember { PagerState() }
|
|
||||||
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(10.dp)
|
|
||||||
.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
CloseButton(onCancel = onDismiss)
|
|
||||||
|
|
||||||
SaveToGallery(url = allImages[pagerState.currentPage])
|
|
||||||
}
|
|
||||||
|
|
||||||
if (allImages.size > 1) {
|
|
||||||
SlidingCarousel(
|
|
||||||
pagerState = pagerState,
|
|
||||||
itemsCount = allImages.size,
|
|
||||||
itemContent = { index ->
|
|
||||||
RenderImageOrVideo(allImages[index])
|
|
||||||
}
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
RenderImageOrVideo(imageUrl)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun RenderImageOrVideo(imageUrl: String) {
|
|
||||||
val removedParamsFromUrl = imageUrl.split("?")[0].lowercase()
|
|
||||||
if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) {
|
|
||||||
AsyncImage(
|
|
||||||
model = imageUrl,
|
|
||||||
contentDescription = stringResource(id = R.string.profile_image),
|
|
||||||
contentScale = ContentScale.FillWidth,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.zoomable(rememberZoomState())
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) {
|
|
||||||
VideoView(imageUrl)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -167,6 +167,8 @@ fun NoteComposeInner(
|
|||||||
ChannelHeader(baseChannel = baseChannel, account = account, navController = navController)
|
ChannelHeader(baseChannel = baseChannel, account = account, navController = navController)
|
||||||
} else if (noteEvent is BadgeDefinitionEvent) {
|
} else if (noteEvent is BadgeDefinitionEvent) {
|
||||||
BadgeDisplay(baseNote = note)
|
BadgeDisplay(baseNote = note)
|
||||||
|
} else if (noteEvent is FileHeaderEvent) {
|
||||||
|
FileHeaderDisplay(note)
|
||||||
} else {
|
} else {
|
||||||
var isNew by remember { mutableStateOf<Boolean>(false) }
|
var isNew by remember { mutableStateOf<Boolean>(false) }
|
||||||
|
|
||||||
@@ -776,6 +778,30 @@ fun BadgeDisplay(baseNote: Note) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun FileHeaderDisplay(note: Note) {
|
||||||
|
val event = (note.event as? FileHeaderEvent) ?: return
|
||||||
|
|
||||||
|
val fullUrl = event.url() ?: return
|
||||||
|
val blurHash = event.blurhash()
|
||||||
|
val hash = event.hash()
|
||||||
|
val description = event.content
|
||||||
|
val removedParamsFromUrl = fullUrl.split("?")[0].lowercase()
|
||||||
|
val isImage = imageExtensions.any { removedParamsFromUrl.endsWith(it) }
|
||||||
|
val isVideo = videoExtensions.any { removedParamsFromUrl.endsWith(it) }
|
||||||
|
|
||||||
|
if (isImage || isVideo) {
|
||||||
|
val content = if (isImage) {
|
||||||
|
ZoomableImage(fullUrl, description, hash, blurHash)
|
||||||
|
} else {
|
||||||
|
ZoomableVideo(fullUrl, description, hash)
|
||||||
|
}
|
||||||
|
ZoomableContentView(content = content, listOf(content))
|
||||||
|
} else {
|
||||||
|
UrlPreview(fullUrl, "$fullUrl ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun LongFormHeader(noteEvent: LongTextNoteEvent, note: Note, loggedIn: User) {
|
private fun LongFormHeader(noteEvent: LongTextNoteEvent, note: Note, loggedIn: User) {
|
||||||
Row(
|
Row(
|
||||||
|
|||||||
@@ -89,6 +89,8 @@ fun ChannelScreen(
|
|||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val channelScreenModel: NewPostViewModel = viewModel()
|
val channelScreenModel: NewPostViewModel = viewModel()
|
||||||
|
|
||||||
|
channelScreenModel.account = account
|
||||||
|
|
||||||
if (account != null && channelId != null) {
|
if (account != null && channelId != null) {
|
||||||
val replyTo = remember { mutableStateOf<Note?>(null) }
|
val replyTo = remember { mutableStateOf<Note?>(null) }
|
||||||
|
|
||||||
|
|||||||
@@ -67,6 +67,8 @@ fun ChatroomScreen(userId: String?, accountViewModel: AccountViewModel, navContr
|
|||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val chatRoomScreenModel: NewPostViewModel = viewModel()
|
val chatRoomScreenModel: NewPostViewModel = viewModel()
|
||||||
|
|
||||||
|
chatRoomScreenModel.account = account
|
||||||
|
|
||||||
if (account != null && userId != null) {
|
if (account != null && userId != null) {
|
||||||
val replyTo = remember { mutableStateOf<Note?>(null) }
|
val replyTo = remember { mutableStateOf<Note?>(null) }
|
||||||
|
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImage
|
|||||||
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
|
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
|
||||||
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
|
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
|
||||||
import com.vitorpamplona.amethyst.ui.components.ZoomableImageDialog
|
import com.vitorpamplona.amethyst.ui.components.ZoomableImageDialog
|
||||||
|
import com.vitorpamplona.amethyst.ui.components.figureOutMimeType
|
||||||
import com.vitorpamplona.amethyst.ui.dal.UserProfileBookmarksFeedFilter
|
import com.vitorpamplona.amethyst.ui.dal.UserProfileBookmarksFeedFilter
|
||||||
import com.vitorpamplona.amethyst.ui.dal.UserProfileConversationsFeedFilter
|
import com.vitorpamplona.amethyst.ui.dal.UserProfileConversationsFeedFilter
|
||||||
import com.vitorpamplona.amethyst.ui.dal.UserProfileFollowersFeedFilter
|
import com.vitorpamplona.amethyst.ui.dal.UserProfileFollowersFeedFilter
|
||||||
@@ -401,8 +402,9 @@ private fun ProfileHeader(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (zoomImageDialogOpen) {
|
val profilePic = baseUser.profilePicture()
|
||||||
ZoomableImageDialog(baseUser.profilePicture()!!, onDismiss = { zoomImageDialogOpen = false })
|
if (zoomImageDialogOpen && profilePic != null) {
|
||||||
|
ZoomableImageDialog(figureOutMimeType(profilePic), onDismiss = { zoomImageDialogOpen = false })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -706,7 +708,7 @@ private fun DrawBanner(baseUser: User) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
if (zoomImageDialogOpen) {
|
if (zoomImageDialogOpen) {
|
||||||
ZoomableImageDialog(imageUrl = banner, onDismiss = { zoomImageDialogOpen = false })
|
ZoomableImageDialog(imageUrl = figureOutMimeType(banner), onDismiss = { zoomImageDialogOpen = false })
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Image(
|
Image(
|
||||||
|
|||||||
@@ -292,4 +292,9 @@
|
|||||||
<string name="lightning_create_and_add_invoice">Create and Add</string>
|
<string name="lightning_create_and_add_invoice">Create and Add</string>
|
||||||
<string name="poll_author_no_vote">Poll authors can\'t vote in their own polls.</string>
|
<string name="poll_author_no_vote">Poll authors can\'t vote in their own polls.</string>
|
||||||
<string name="poll_hashtag" translatable="false">#zappoll</string>
|
<string name="poll_hashtag" translatable="false">#zappoll</string>
|
||||||
|
|
||||||
|
|
||||||
|
<string name="hash_verification_passed">Image is the same since the post</string>
|
||||||
|
<string name="hash_verification_failed">Image has changed. The author might not have seen the change</string>
|
||||||
|
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
Reference in New Issue
Block a user